table.tsx 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import {Fragment, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import PanelTable, {PanelTableHeader} from 'sentry/components/panels/panelTable';
  4. import {Tooltip} from 'sentry/components/tooltip';
  5. import {t} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. import type {MetricsQueryApiResponse} from 'sentry/types';
  8. import {formatMetricsUsingUnitAndOp} from 'sentry/utils/metrics/formatters';
  9. import {formatMRIField, MRIToField, parseMRI} from 'sentry/utils/metrics/mri';
  10. import {
  11. isMetricFormula,
  12. type MetricsQueryApiQueryParams,
  13. type MetricsQueryApiRequestQuery,
  14. } from 'sentry/utils/metrics/useMetricsQuery';
  15. import {LoadingScreen} from 'sentry/views/starfish/components/chart';
  16. interface MetricTableContainerProps {
  17. isLoading: boolean;
  18. metricQueries: MetricsQueryApiRequestQuery[];
  19. timeseriesData?: MetricsQueryApiResponse;
  20. }
  21. export function MetricTableContainer({
  22. timeseriesData,
  23. metricQueries,
  24. isLoading,
  25. }: MetricTableContainerProps) {
  26. const tableData = useMemo(() => {
  27. return timeseriesData ? getTableData(timeseriesData, metricQueries) : undefined;
  28. }, [timeseriesData, metricQueries]);
  29. if (!tableData) {
  30. return null;
  31. }
  32. return (
  33. <Fragment>
  34. <LoadingScreen loading={isLoading} />
  35. <MetricTable isLoading={isLoading} data={tableData} borderless />
  36. </Fragment>
  37. );
  38. }
  39. interface MetricTableProps {
  40. data: {
  41. headers: {name: string; type: string}[];
  42. rows: any[];
  43. };
  44. isLoading: boolean;
  45. borderless?: boolean;
  46. }
  47. export function MetricTable({isLoading, data, borderless}: MetricTableProps) {
  48. function renderRow(row: any, index: number) {
  49. return data.headers.map((column, columnIndex) => {
  50. const key = `${index}-${columnIndex}:${column}`;
  51. const value = row[column.name];
  52. if (!value) {
  53. return (
  54. <TableCell type={column.type} key={key} noValue>
  55. {column.type === 'field' ? 'n/a' : '(none)'}
  56. </TableCell>
  57. );
  58. }
  59. return (
  60. <TableCell type={column.type} key={key}>
  61. {value}
  62. </TableCell>
  63. );
  64. });
  65. }
  66. return (
  67. <StyledPanelTable
  68. borderless={borderless}
  69. headers={data.headers.map((column, index) => {
  70. const header = formatMRIField(column.name);
  71. return (
  72. <HeaderCell key={index} type={column.type}>
  73. <Tooltip title={header}>{header}</Tooltip>
  74. </HeaderCell>
  75. );
  76. })}
  77. stickyHeaders
  78. isLoading={isLoading}
  79. emptyMessage={t('No results')}
  80. >
  81. {data.rows.map(renderRow)}
  82. </StyledPanelTable>
  83. );
  84. }
  85. const equalGroupBys = (a: Record<string, any>, b: Record<string, any>) => {
  86. return JSON.stringify(a) === JSON.stringify(b);
  87. };
  88. const getEmptyGroup = (tags: string[]) =>
  89. tags.reduce((acc, tag) => {
  90. acc[tag] = '';
  91. return acc;
  92. }, {});
  93. function getGroupByCombos(
  94. queries: MetricsQueryApiRequestQuery[],
  95. results: MetricsQueryApiResponse['data']
  96. ): Record<string, string>[] {
  97. const groupBys = Array.from(new Set(queries.flatMap(query => query.groupBy ?? [])));
  98. const emptyBy = getEmptyGroup(groupBys);
  99. const allCombos = results.flatMap(group => {
  100. return group.map(entry => ({...emptyBy, ...entry.by}));
  101. });
  102. const uniqueCombos = allCombos.filter(
  103. (combo, index, self) => index === self.findIndex(other => equalGroupBys(other, combo))
  104. );
  105. return uniqueCombos;
  106. }
  107. type Row = Record<string, string | undefined>;
  108. interface TableData {
  109. headers: {name: string; type: string}[];
  110. rows: Row[];
  111. }
  112. export function getTableData(
  113. data: MetricsQueryApiResponse,
  114. queries: MetricsQueryApiQueryParams[]
  115. ): TableData {
  116. const filteredQueries = queries.filter(
  117. query => !isMetricFormula(query)
  118. ) as MetricsQueryApiRequestQuery[];
  119. const fields = filteredQueries.map(query => MRIToField(query.mri, query.op));
  120. const tags = [...new Set(filteredQueries.flatMap(query => query.groupBy ?? []))];
  121. const normalizedResults = filteredQueries.map((query, index) => {
  122. const queryResults = data.data[index];
  123. const metaUnit = data.meta[index]?.[1]?.unit;
  124. const normalizedGroupResults = queryResults.map(group => {
  125. return {
  126. by: {...getEmptyGroup(tags), ...group.by},
  127. totals: formatMetricsUsingUnitAndOp(
  128. group.totals,
  129. // TODO(ogi): switch to using the meta unit when it's available
  130. metaUnit ?? parseMRI(query.mri)?.unit!,
  131. query.op
  132. ),
  133. };
  134. });
  135. const key = MRIToField(query.mri, query.op);
  136. return {field: key, results: normalizedGroupResults};
  137. }, {});
  138. const groupByCombos = getGroupByCombos(filteredQueries, data.data);
  139. const rows: Row[] = groupByCombos.map(combo => {
  140. const row: Row = {...combo};
  141. normalizedResults.forEach(({field, results}) => {
  142. const entry = results.find(e => equalGroupBys(e.by, combo));
  143. row[field] = entry?.totals;
  144. });
  145. return row;
  146. });
  147. const tableData = {
  148. headers: [
  149. ...tags.map(tagName => ({name: tagName, type: 'tag'})),
  150. ...fields.map(f => ({name: f, type: 'field'})),
  151. ],
  152. rows,
  153. };
  154. return tableData;
  155. }
  156. const Cell = styled('div')<{type?: string}>`
  157. text-align: ${p => (p.type === 'field' ? 'right' : 'left')};
  158. `;
  159. const StyledPanelTable = styled(PanelTable)<{borderless?: boolean}>`
  160. position: relative;
  161. display: grid;
  162. overflow: auto;
  163. margin: 0;
  164. margin-top: ${space(1.5)};
  165. border-radius: ${p => p.theme.borderRadius};
  166. font-size: ${p => p.theme.fontSizeMedium};
  167. box-shadow: none;
  168. ${p =>
  169. p.borderless &&
  170. `border-radius: 0 0 ${p.theme.borderRadius} ${p.theme.borderRadius};
  171. border-left: 0;
  172. border-right: 0;
  173. border-bottom: 0;`}
  174. ${PanelTableHeader} {
  175. height: min-content;
  176. }
  177. `;
  178. const HeaderCell = styled(Cell)`
  179. padding: 0 ${space(0.5)};
  180. `;
  181. export const TableCell = styled(Cell)<{noValue?: boolean}>`
  182. padding: ${space(1)} ${space(3)};
  183. ${p => p.noValue && `color: ${p.theme.gray300};`}
  184. `;