import {Fragment, memo, useCallback} from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import colorFn from 'color';
import {LinkButton} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import TextOverflow from 'sentry/components/textOverflow';
import {Tooltip} from 'sentry/components/tooltip';
import {IconArrow, IconLightning, IconReleases} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {trackAnalytics} from 'sentry/utils/analytics';
import {getUtcDateString} from 'sentry/utils/dates';
import {DEFAULT_SORT_STATE} from 'sentry/utils/metrics/constants';
import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
import type {MetricWidgetQueryParams, SortState} from 'sentry/utils/metrics/types';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import type {Series} from 'sentry/views/ddm/widget';
import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
export const SummaryTable = memo(function SummaryTable({
series,
operation,
onRowClick,
onSortChange,
sort = DEFAULT_SORT_STATE as SortState,
setHoveredSeries,
}: {
onRowClick: (series: MetricWidgetQueryParams['focusedSeries']) => void;
onSortChange: (sortState: SortState) => void;
series: Series[];
operation?: string;
setHoveredSeries?: (seriesName: string) => void;
sort?: SortState;
}) {
const {selection} = usePageFilters();
const organization = useOrganization();
const hasActions = series.some(s => s.release || s.transaction);
const hasMultipleSeries = series.length > 1;
const changeSort = useCallback(
(name: SortState['name']) => {
trackAnalytics('ddm.widget.sort', {
organization,
by: name ?? '(none)',
order: sort.order,
});
Sentry.metrics.increment('ddm.widget.sort', 1, {
tags: {
by: name ?? '(none)',
order: sort.order,
},
});
if (sort.name === name) {
if (sort.order === 'desc') {
onSortChange(DEFAULT_SORT_STATE as SortState);
} else if (sort.order === 'asc') {
onSortChange({
name,
order: 'desc',
});
} else {
onSortChange({
name,
order: 'asc',
});
}
} else {
onSortChange({
name,
order: 'asc',
});
}
},
[sort, onSortChange, organization]
);
const releaseTo = (release: string) => {
return {
pathname: `/organizations/${organization.slug}/releases/${encodeURIComponent(
release
)}/`,
query: {
pageStart: selection.datetime.start,
pageEnd: selection.datetime.end,
pageStatsPeriod: selection.datetime.period,
project: selection.projects,
environment: selection.environments,
},
};
};
const transactionTo = (transaction: string) =>
transactionSummaryRouteWithQuery({
orgSlug: organization.slug,
transaction,
projectID: selection.projects.map(p => String(p)),
query: {
query: '',
environment: selection.environments,
start: selection.datetime.start
? getUtcDateString(selection.datetime.start)
: undefined,
end: selection.datetime.end
? getUtcDateString(selection.datetime.end)
: undefined,
statsPeriod: selection.datetime.period,
},
});
const rows = series
.map(s => {
return {
...s,
...getValues(s.data),
name: s.seriesName,
};
})
.sort((a, b) => {
const {name, order} = sort;
if (!name) {
return 0;
}
if (name === 'name') {
return order === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name);
}
const aValue = a[name] ?? 0;
const bValue = b[name] ?? 0;
return order === 'asc' ? aValue - bValue : bValue - aValue;
});
return (
{t('Name')}
{t('Avg')}
{t('Min')}
{t('Max')}
{t('Sum')}
{hasActions && (
{t('Actions')}
)}
{
if (hasMultipleSeries) {
setHoveredSeries?.('');
}
}}
>
{rows.map(
({
name,
seriesName,
groupBy,
color,
hidden,
unit,
transaction,
release,
avg,
min,
max,
sum,
}) => {
return (
{
if (hasMultipleSeries) {
onRowClick({
seriesName,
groupBy,
});
}
}}
onMouseEnter={() => {
if (hasMultipleSeries) {
setHoveredSeries?.(seriesName);
}
}}
>
|
{name}
{/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
{formatMetricsUsingUnitAndOp(avg, unit, operation)} |
{formatMetricsUsingUnitAndOp(min, unit, operation)} |
{formatMetricsUsingUnitAndOp(max, unit, operation)} |
{formatMetricsUsingUnitAndOp(sum, unit, operation)} |
{hasActions && (
{transaction && (
)}
{release && (
)}
|
)}
);
}
)}
);
});
function SortableHeaderCell({
sortState,
name,
right,
children,
onClick,
}: {
children: React.ReactNode;
name: SortState['name'];
onClick: (name: SortState['name']) => void;
sortState: SortState;
right?: boolean;
}) {
const sortIcon =
sortState.name === name ? (
) : (
''
);
return (
{
onClick(name);
}}
right={right}
>
{sortIcon} {children}
);
}
function getValues(seriesData: Series['data']) {
if (!seriesData) {
return {min: null, max: null, avg: null, sum: null};
}
const res = seriesData.reduce(
(acc, {value}) => {
if (value === null) {
return acc;
}
acc.min = Math.min(acc.min, value);
acc.max = Math.max(acc.max, value);
acc.sum += value;
acc.definedDatapoints += 1;
return acc;
},
{min: Infinity, max: -Infinity, sum: 0, definedDatapoints: 0}
);
return {min: res.min, max: res.max, sum: res.sum, avg: res.sum / res.definedDatapoints};
}
// TODO(ddm): PanelTable component proved to be a bit too opinionated for this use case,
// so we're using a custom styled component instead. Figure out what we want to do here
const SummaryTableWrapper = styled(`div`)<{hasActions: boolean}>`
display: grid;
grid-template-columns: ${p =>
p.hasActions ? '24px 8fr repeat(5, 1fr)' : '24px 8fr repeat(4, 1fr)'};
max-height: 200px;
overflow-y: auto;
scrollbar-gutter: stable;
`;
// TODO(ddm): This is a copy of PanelTableHeader, try to figure out how to reuse it
const HeaderCell = styled('div')<{disabled?: boolean; right?: boolean}>`
color: ${p => p.theme.subText};
font-size: ${p => p.theme.fontSizeSmall};
font-weight: 600;
text-transform: uppercase;
border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
line-height: 1;
display: flex;
flex-direction: row;
justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
padding: ${space(0.5)} ${space(1)};
gap: ${space(0.5)};
user-select: none;
:hover {
cursor: ${p => (p.disabled ? 'auto' : 'pointer')};
background-color: ${p => (p.disabled ? p.theme.background : p.theme.bodyBackground)};
}
`;
const Cell = styled('div')<{right?: boolean}>`
display: flex;
padding: ${space(0.25)} ${space(1)};
align-items: center;
justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
white-space: nowrap;
`;
const TextOverflowCell = styled(Cell)`
min-width: 0;
`;
const ColorDot = styled(`div`)<{color: string; isHidden: boolean}>`
border: 1px solid ${p => p.color};
border-radius: 50%;
width: ${space(1)};
height: ${space(1)};
`;
const TableBodyWrapper = styled('div')`
display: contents;
`;
const CellWrapper = styled('div')`
display: contents;
&:hover {
cursor: pointer;
${Cell}, ${TextOverflowCell} {
background-color: ${p => p.theme.bodyBackground};
}
}
`;