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 => (