import {useCallback, useMemo} from 'react'; import styled from '@emotion/styled'; import {PanelTable, PanelTableHeader} from 'sentry/components/panels/panelTable'; import TextOverflow from 'sentry/components/textOverflow'; import {IconArrow} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {MetricsQueryApiResponse} from 'sentry/types/metrics'; import {isNotQueryOnly, unescapeMetricsFormula} from 'sentry/utils/metrics'; import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters'; import {formatMRIField, MRIToField} from 'sentry/utils/metrics/mri'; import { isMetricFormula, type MetricsQueryApiQueryParams, type MetricsQueryApiRequestQuery, } from 'sentry/utils/metrics/useMetricsQuery'; import type {Order} from 'sentry/views/dashboards/metrics/types'; import {LoadingScreen} from 'sentry/views/insights/common/components/chart'; interface MetricTableContainerProps { isLoading: boolean; metricQueries: MetricsQueryApiQueryParams[]; timeseriesData?: MetricsQueryApiResponse; } export function MetricTableContainer({ timeseriesData, metricQueries, isLoading, }: MetricTableContainerProps) { const tableData = useMemo(() => { return timeseriesData ? getTableData(timeseriesData, metricQueries) : {headers: [], rows: []}; }, [timeseriesData, metricQueries]); return ; } interface MetricTableProps { data: TableData; isLoading: boolean; borderless?: boolean; onOrderChange?: ({id, order}: {id: number; order: Order}) => void; } export function MetricTable({ isLoading, data, borderless, onOrderChange, }: MetricTableProps) { const handleCellClick = useCallback( column => { if (!onOrderChange) { return; } const {order} = column; const newOrder = order === 'desc' ? 'asc' : 'desc'; onOrderChange({...column, order: newOrder}); }, [onOrderChange] ); function renderRow(row: Row, index: number) { return data.headers.map((column, columnIndex) => { const key = `${index}-${columnIndex}:${column.name}`; const value = row[column.name].formattedValue ?? row[column.name].value; if (!value) { return ( {column.type === 'field' ? 'n/a' : '(none)'} ); } return ( {value} ); }); } if (isLoading) { return ; } return ( { return ( handleCellClick(column)} disabled={column.type !== 'field' || !onOrderChange} > {column.order && ( )} {column.label} ); })} stickyHeaders emptyMessage={t('No results')} > {data.rows.map(renderRow)} ); } const equalGroupBys = (a: Record, b: Record) => { return JSON.stringify(a) === JSON.stringify(b); }; const getEmptyGroup = (tags: string[]) => tags.reduce((acc, tag) => { acc[tag] = ''; return acc; }, {}); function getGroupByCombos( queries: MetricsQueryApiRequestQuery[], results: MetricsQueryApiResponse['data'] ): Record[] { const groupBys = Array.from(new Set(queries.flatMap(query => query.groupBy ?? []))); const emptyBy = getEmptyGroup(groupBys); const allCombos = results.flatMap(group => { return group.map(entry => ({...emptyBy, ...entry.by})); }); const uniqueCombos = allCombos.filter( (combo, index, self) => index === self.findIndex(other => equalGroupBys(other, combo)) ); return uniqueCombos; } type Row = Record; interface TableData { headers: { label: string; name: string; order: Order; type: string; }[]; rows: Row[]; } export function getTableData( data: MetricsQueryApiResponse, expressions: MetricsQueryApiQueryParams[] ): TableData { const queries = expressions.filter(isNotQueryOnly) as MetricsQueryApiRequestQuery[]; // @ts-expect-error TODO(metrics): use DashboardMetricsExpression type const shownExpressions = expressions.filter(e => !e.isHidden); const tags = [...new Set(queries.flatMap(query => query.groupBy ?? []))]; const normalizedResults = shownExpressions.map((expression, index) => { const expressionResults = data.data[index]; const meta = data.meta[index]; const lastMetaEntry = data.meta[index]?.[meta.length - 1]; const metaUnit = (lastMetaEntry && 'unit' in lastMetaEntry && lastMetaEntry.unit) || 'none'; const normalizedGroupResults = expressionResults.map(group => { return { by: {...getEmptyGroup(tags), ...group.by}, totals: group.totals, formattedValue: formatMetricUsingUnit(group.totals, metaUnit), }; }); return {name: expression.name, results: normalizedGroupResults}; }, {}); const groupByCombos = getGroupByCombos(queries, data.data); const rows: Row[] = groupByCombos.map(combo => { const row = Object.entries(combo).reduce((acc, [key, value]) => { acc[key] = {value}; return acc; }, {}); normalizedResults.forEach(({name, results}) => { const entry = results.find(e => equalGroupBys(e.by, combo)); row[name] = {value: entry?.totals, formattedValue: entry?.formattedValue}; }); return row; }); const headers = [ ...tags.map(tagName => ({ name: tagName, label: tagName, type: 'tag', order: undefined, })), ...shownExpressions.map(query => ({ name: query.name, // @ts-expect-error TODO(metrics): use DashboardMetricsExpression type id: query.id, label: // TODO(metrics): consider consolidating with getMetricQueryName (different types) query.alias ?? (isMetricFormula(query) ? unescapeMetricsFormula(query.formula) : formatMRIField(MRIToField(query.mri, query.aggregation))), type: 'field', order: query.orderBy, })), ]; const tableData = { headers, rows: sortRows(rows, headers), }; return tableData; } function sortRows(rows: Row[], headers: TableData['headers']) { const orderedByColumn = headers.find(header => !!header.order); if (!orderedByColumn) { return rows; } const sorted = rows.sort((a, b) => { const aValue = a[orderedByColumn.name]?.value ?? ''; const bValue = b[orderedByColumn.name]?.value ?? ''; if (orderedByColumn.order === 'asc') { return aValue > bValue ? 1 : -1; } return aValue < bValue ? 1 : -1; }); return sorted; } const Cell = styled('div')<{type?: string}>` display: flex; flex-direction: row; justify-content: ${p => (p.type === 'field' ? ' flex-end' : ' flex-start')}; `; const StyledPanelTable = styled(PanelTable)<{borderless?: boolean}>` position: relative; display: grid; overflow: auto; margin: 0; margin-top: ${space(1.5)}; border-radius: ${p => p.theme.borderRadius}; font-size: ${p => p.theme.fontSizeMedium}; box-shadow: none; ${p => p.borderless && `border-radius: 0 0 ${p.theme.borderRadius} ${p.theme.borderRadius}; border-left: 0; border-right: 0; border-bottom: 0;`} ${PanelTableHeader} { height: min-content; } `; const HeaderCell = styled('div')<{disabled: boolean; type?: string}>` padding: 0 ${space(0.5)}; display: flex; flex-direction: row; align-items: stretch; gap: ${space(0.5)}; cursor: ${p => (p.disabled ? 'default' : 'pointer')}; justify-content: ${p => (p.type === 'field' ? ' flex-end' : ' flex-start')}; `; export const TableCell = styled(Cell)<{noValue?: boolean}>` padding: ${space(1)} ${space(3)}; ${p => p.noValue && `color: ${p.theme.gray300};`} `;