import {Fragment, memo, useCallback} from 'react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';
import colorFn from 'color';
import {Button, LinkButton} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import type {Series} from 'sentry/components/metrics/chart/types';
import TextOverflow from 'sentry/components/textOverflow';
import {Tooltip} from 'sentry/components/tooltip';
import {IconArrow, IconFilter, IconLightning, IconReleases} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {MetricAggregation} from 'sentry/types/metrics';
import {trackAnalytics} from 'sentry/utils/analytics';
import {getUtcDateString} from 'sentry/utils/dates';
import {DEFAULT_SORT_STATE} from 'sentry/utils/metrics/constants';
import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
import {
type FocusedMetricsSeries,
MetricSeriesFilterUpdateType,
type SortState,
} from 'sentry/utils/metrics/types';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
export const SummaryTable = memo(function SummaryTable({
series,
onRowClick,
onColorDotClick,
onSortChange,
sort = DEFAULT_SORT_STATE as SortState,
onRowHover,
onRowFilter,
}: {
onRowClick: (series: FocusedMetricsSeries) => void;
onSortChange: (sortState: SortState) => void;
series: Series[];
onColorDotClick?: (series: FocusedMetricsSeries) => void;
onRowFilter?: (
index: number,
series: FocusedMetricsSeries,
updateType: MetricSeriesFilterUpdateType
) => void;
onRowHover?: (seriesName: string) => void;
sort?: SortState;
}) {
const {selection} = usePageFilters();
const organization = useOrganization();
const totalColumns = getTotalColumns(series);
const canFilter = series.length > 1 && !!onRowFilter;
const hasActions = series.some(s => s.release || s.transaction) || canFilter;
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 handleRowFilter = useCallback(
(
index: number | undefined,
row: FocusedMetricsSeries,
updateType: MetricSeriesFilterUpdateType
) => {
if (index === undefined) {
return;
}
trackAnalytics('ddm.widget.add_row_filter', {
organization,
});
onRowFilter?.(index, row, updateType);
},
[onRowFilter, 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,
...getTotals(s),
};
})
.sort((a, b) => {
const {name, order} = sort;
if (!name) {
return 0;
}
if (name === 'name') {
return order === 'asc'
? a.seriesName.localeCompare(b.seriesName)
: b.seriesName.localeCompare(a.seriesName);
}
const aValue = a[name] ?? 0;
const bValue = b[name] ?? 0;
return order === 'asc' ? aValue - bValue : bValue - aValue;
});
// We do not want to render the table if there is no data to display
// If the data is being loaded, then the whole chart will be in a loading state and this is being handled by the parent component
if (!rows.length) {
return null;
}
return (
{t('Name')}
{totalColumns.map(aggregate => (
{aggregate}
))}
{hasActions && }
{
if (hasMultipleSeries) {
onRowHover?.('');
}
}}
>
{rows.map(row => {
return (
{
if (hasMultipleSeries) {
onRowClick({id: row.id, groupBy: row.groupBy});
}
}}
onMouseEnter={() => {
if (hasMultipleSeries) {
onRowHover?.(row.id);
}
}}
>
{
event.stopPropagation();
if (hasMultipleSeries) {
onColorDotClick?.(row);
}
}}
>
|
}
delay={500}
overlayStyle={{maxWidth: '80vw'}}
>
{row.seriesName}
{totalColumns.map(aggregate => (
{row[aggregate]
? formatMetricUsingUnit(row[aggregate], row.unit)
: '\u2014'}
))}
{hasActions && (
{row.transaction && (
)}
{row.release && (
)}
{/* do not show add/exclude filter if there's no groupby or if this is an equation */}
{Object.keys(row.groupBy ?? {}).length > 0 &&
!row.isEquationSeries && (
{
handleRowFilter(
row.queryIndex,
row,
MetricSeriesFilterUpdateType.ADD
);
},
},
{
key: 'exclude-from-filter',
label: t('Exclude from filter'),
size: 'sm',
onAction: () => {
handleRowFilter(
row.queryIndex,
row,
MetricSeriesFilterUpdateType.EXCLUDE
);
},
},
]}
trigger={triggerProps => (
)}
);
})}
);
});
function FullSeriesName({
seriesName,
groupBy,
}: {
seriesName: string;
groupBy?: Record;
}) {
if (!groupBy || Object.keys(groupBy).length === 0) {
return {seriesName};
}
const goupByEntries = Object.entries(groupBy);
return (
{goupByEntries.map(([key, value], index) => {
const formattedValue = value || t('(none)');
return (
{`${key}:`}
{index === goupByEntries.length - 1 ? formattedValue : `${formattedValue}, `}
);
})}
);
}
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 ? (
) : (
''
);
if (right) {
return (
{
onClick(name);
}}
right
>
{sortIcon} {children}
);
}
return (
{
onClick(name);
}}
>
{children} {sortIcon}
);
}
// These aggregates can always be shown as we can calculate them on the frontend
const DEFAULT_TOTALS: MetricAggregation[] = ['avg', 'min', 'max', 'sum'];
// Count and count_unique will always match the sum column
const TOTALS_BLOCKLIST: MetricAggregation[] = ['count', 'count_unique'];
function getTotalColumns(series: Series[]) {
const totals = new Set();
series.forEach(({aggregate}) => {
if (!DEFAULT_TOTALS.includes(aggregate) && !TOTALS_BLOCKLIST.includes(aggregate)) {
totals.add(aggregate);
}
});
return DEFAULT_TOTALS.concat(Array.from(totals).sort((a, b) => a.localeCompare(b)));
}
function getTotals(series: Series) {
const {data, total, aggregate} = series;
if (!data) {
return {min: null, max: null, avg: null, sum: null};
}
const res = data.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}
);
const values: Partial> = {
min: res.min,
max: res.max,
sum: res.sum,
avg: res.sum / res.definedDatapoints,
};
values[aggregate] = total;
return values;
}
const SummaryTableWrapper = styled(`div`)<{
hasActions: boolean;
totalColumnsCount: number;
}>`
display: grid;
/* padding | color dot | name | avg | min | max | sum | total | actions | padding */
grid-template-columns:
${space(0.75)} ${space(3)} 8fr repeat(
${p => (p.hasActions ? p.totalColumnsCount + 1 : p.totalColumnsCount)},
max-content
)
${space(0.75)};
max-height: 200px;
overflow-x: hidden;
overflow-y: auto;
border: 1px solid ${p => p.theme.border};
border-radius: ${p => p.theme.borderRadius};
font-size: ${p => p.theme.fontSizeSmall};
`;
const TableBodyWrapper = styled(`div`)<{hasActions: boolean}>`
display: contents;
`;
const HeaderCell = styled('div')<{disabled?: boolean; right?: boolean}>`
display: flex;
flex-direction: row;
text-transform: uppercase;
justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
align-items: center;
gap: ${space(0.5)};
padding: ${space(0.25)} ${space(0.75)};
line-height: ${p => p.theme.text.lineHeightBody};
font-weight: ${p => p.theme.fontWeightBold};
font-family: ${p => p.theme.text.family};
color: ${p => p.theme.subText};
user-select: none;
background-color: ${p => p.theme.backgroundSecondary};
border-radius: 0;
border-bottom: 1px solid ${p => p.theme.border};
top: 0;
position: sticky;
z-index: 1;
&:hover {
cursor: ${p => (p.disabled ? 'default' : 'pointer')};
}
`;
const Cell = styled('div')<{right?: boolean}>`
display: flex;
padding: ${space(0.25)} ${space(0.75)};
align-items: center;
justify-content: flex-start;
white-space: nowrap;
`;
const NumberCell = styled(Cell)`
justify-content: flex-end;
font-variant-numeric: tabular-nums;
`;
const CenterCell = styled(Cell)`
justify-content: center;
`;
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 PaddingCell = styled(Cell)`
padding: 0;
`;
const Row = styled('div')`
display: contents;
&:hover {
cursor: pointer;
${Cell}, ${NumberCell}, ${CenterCell}, ${PaddingCell}, ${TextOverflowCell} {
background-color: ${p => p.theme.bodyBackground};
}
}
`;