123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285 |
- 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';
- 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/starfish/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 <MetricTable isLoading={isLoading} data={tableData} borderless />;
- }
- 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 (
- <TableCell type={column.type} key={key} noValue>
- {column.type === 'field' ? 'n/a' : '(none)'}
- </TableCell>
- );
- }
- return (
- <TableCell type={column.type} key={key}>
- {value}
- </TableCell>
- );
- });
- }
- if (isLoading) {
- return <LoadingScreen loading />;
- }
- return (
- <StyledPanelTable
- borderless={borderless}
- headers={data.headers.map((column, index) => {
- return (
- <HeaderCell
- key={index}
- type={column.type}
- onClick={() => handleCellClick(column)}
- disabled={column.type !== 'field' || !onOrderChange}
- >
- {column.order && (
- <IconArrow direction={column.order === 'asc' ? 'up' : 'down'} size="xs" />
- )}
- <TextOverflow>{column.label}</TextOverflow>
- </HeaderCell>
- );
- })}
- stickyHeaders
- emptyMessage={t('No results')}
- >
- {data.rows.map(renderRow)}
- </StyledPanelTable>
- );
- }
- const equalGroupBys = (a: Record<string, unknown>, b: Record<string, unknown>) => {
- 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<string, string>[] {
- 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<string, {formattedValue?: string; value?: number}>;
- 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.op))),
- 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};`}
- `;
|