table.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import {Fragment, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {PanelTable, PanelTableHeader} from 'sentry/components/panels/panelTable';
  4. import TextOverflow from 'sentry/components/textOverflow';
  5. import {IconArrow} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import {space} from 'sentry/styles/space';
  8. import type {MetricsQueryApiResponse} from 'sentry/types';
  9. import {unescapeMetricsFormula} from 'sentry/utils/metrics';
  10. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  11. import {formatMRIField, MRIToField} from 'sentry/utils/metrics/mri';
  12. import {
  13. isMetricFormula,
  14. type MetricsQueryApiQueryParams,
  15. type MetricsQueryApiRequestQuery,
  16. } from 'sentry/utils/metrics/useMetricsQuery';
  17. import type {Order} from 'sentry/views/dashboards/metrics/types';
  18. import {LoadingScreen} from 'sentry/views/starfish/components/chart';
  19. interface MetricTableContainerProps {
  20. isLoading: boolean;
  21. metricQueries: MetricsQueryApiQueryParams[];
  22. timeseriesData?: MetricsQueryApiResponse;
  23. }
  24. export function MetricTableContainer({
  25. timeseriesData,
  26. metricQueries,
  27. isLoading,
  28. }: MetricTableContainerProps) {
  29. const tableData = useMemo(() => {
  30. return timeseriesData
  31. ? getTableData(timeseriesData, metricQueries)
  32. : {headers: [], rows: []};
  33. }, [timeseriesData, metricQueries]);
  34. return (
  35. <Fragment>
  36. <LoadingScreen loading={isLoading} />
  37. <MetricTable isLoading={isLoading} data={tableData} borderless />
  38. </Fragment>
  39. );
  40. }
  41. interface MetricTableProps {
  42. data: TableData;
  43. isLoading: boolean;
  44. borderless?: boolean;
  45. onOrderChange?: ({id, order}: {id: number; order: Order}) => void;
  46. }
  47. export function MetricTable({
  48. isLoading,
  49. data,
  50. borderless,
  51. onOrderChange,
  52. }: MetricTableProps) {
  53. const handleCellClick = useCallback(
  54. column => {
  55. if (!onOrderChange) {
  56. return;
  57. }
  58. const {order} = column;
  59. const newOrder = order === 'desc' ? 'asc' : 'desc';
  60. onOrderChange({...column, order: newOrder});
  61. },
  62. [onOrderChange]
  63. );
  64. function renderRow(row: Row, index: number) {
  65. return data.headers.map((column, columnIndex) => {
  66. const key = `${index}-${columnIndex}:${column.name}`;
  67. const value = row[column.name].formattedValue ?? row[column.name].value;
  68. if (!value) {
  69. return (
  70. <TableCell type={column.type} key={key} noValue>
  71. {column.type === 'field' ? 'n/a' : '(none)'}
  72. </TableCell>
  73. );
  74. }
  75. return (
  76. <TableCell type={column.type} key={key}>
  77. {value}
  78. </TableCell>
  79. );
  80. });
  81. }
  82. if (isLoading) {
  83. return <LoadingScreen loading />;
  84. }
  85. return (
  86. <StyledPanelTable
  87. borderless={borderless}
  88. headers={data.headers.map((column, index) => {
  89. return (
  90. <HeaderCell
  91. key={index}
  92. type={column.type}
  93. onClick={() => handleCellClick(column)}
  94. disabled={column.type !== 'field' || !onOrderChange}
  95. >
  96. {column.order && (
  97. <IconArrow direction={column.order === 'asc' ? 'up' : 'down'} size="xs" />
  98. )}
  99. <TextOverflow>{column.label}</TextOverflow>
  100. </HeaderCell>
  101. );
  102. })}
  103. stickyHeaders
  104. emptyMessage={t('No results')}
  105. >
  106. {data.rows.map(renderRow)}
  107. </StyledPanelTable>
  108. );
  109. }
  110. const equalGroupBys = (a: Record<string, unknown>, b: Record<string, unknown>) => {
  111. return JSON.stringify(a) === JSON.stringify(b);
  112. };
  113. const getEmptyGroup = (tags: string[]) =>
  114. tags.reduce((acc, tag) => {
  115. acc[tag] = '';
  116. return acc;
  117. }, {});
  118. function getGroupByCombos(
  119. queries: MetricsQueryApiRequestQuery[],
  120. results: MetricsQueryApiResponse['data']
  121. ): Record<string, string>[] {
  122. const groupBys = Array.from(new Set(queries.flatMap(query => query.groupBy ?? [])));
  123. const emptyBy = getEmptyGroup(groupBys);
  124. const allCombos = results.flatMap(group => {
  125. return group.map(entry => ({...emptyBy, ...entry.by}));
  126. });
  127. const uniqueCombos = allCombos.filter(
  128. (combo, index, self) => index === self.findIndex(other => equalGroupBys(other, combo))
  129. );
  130. return uniqueCombos;
  131. }
  132. type Row = Record<string, {formattedValue?: string; value?: number}>;
  133. interface TableData {
  134. headers: {
  135. label: string;
  136. name: string;
  137. order: Order;
  138. type: string;
  139. }[];
  140. rows: Row[];
  141. }
  142. export function getTableData(
  143. data: MetricsQueryApiResponse,
  144. queries: MetricsQueryApiQueryParams[]
  145. ): TableData {
  146. const filteredQueries = queries.filter(
  147. query => !isMetricFormula(query)
  148. ) as MetricsQueryApiRequestQuery[];
  149. const tags = [...new Set(filteredQueries.flatMap(query => query.groupBy ?? []))];
  150. const normalizedResults = queries.map((query, index) => {
  151. const queryResults = data.data[index];
  152. const meta = data.meta[index];
  153. const lastMetaEntry = data.meta[index]?.[meta.length - 1];
  154. const metaUnit =
  155. (lastMetaEntry && 'unit' in lastMetaEntry && lastMetaEntry.unit) || 'none';
  156. const normalizedGroupResults = queryResults.map(group => {
  157. return {
  158. by: {...getEmptyGroup(tags), ...group.by},
  159. totals: group.totals,
  160. formattedValue: formatMetricUsingUnit(group.totals, metaUnit),
  161. };
  162. });
  163. return {name: query.name, results: normalizedGroupResults};
  164. }, {});
  165. const groupByCombos = getGroupByCombos(filteredQueries, data.data);
  166. const rows: Row[] = groupByCombos.map(combo => {
  167. const row = Object.entries(combo).reduce((acc, [key, value]) => {
  168. acc[key] = {value};
  169. return acc;
  170. }, {});
  171. normalizedResults.forEach(({name, results}) => {
  172. const entry = results.find(e => equalGroupBys(e.by, combo));
  173. row[name] = {value: entry?.totals, formattedValue: entry?.formattedValue};
  174. });
  175. return row;
  176. });
  177. const headers = [
  178. ...tags.map(tagName => ({
  179. name: tagName,
  180. label: tagName,
  181. type: 'tag',
  182. order: undefined,
  183. })),
  184. ...queries.map(query => ({
  185. name: query.name,
  186. // @ts-expect-error use DashboardMetricsExpression type
  187. id: query.id,
  188. label: isMetricFormula(query)
  189. ? unescapeMetricsFormula(query.formula)
  190. : formatMRIField(MRIToField(query.mri, query.op)),
  191. type: 'field',
  192. order: query.orderBy,
  193. })),
  194. ];
  195. const tableData = {
  196. headers,
  197. rows: sortRows(rows, headers),
  198. };
  199. return tableData;
  200. }
  201. function sortRows(rows: Row[], headers: TableData['headers']) {
  202. const orderedByColumn = headers.find(header => !!header.order);
  203. if (!orderedByColumn) {
  204. return rows;
  205. }
  206. const sorted = rows.sort((a, b) => {
  207. const aValue = a[orderedByColumn.name]?.value ?? '';
  208. const bValue = b[orderedByColumn.name]?.value ?? '';
  209. if (orderedByColumn.order === 'asc') {
  210. return aValue > bValue ? 1 : -1;
  211. }
  212. return aValue < bValue ? 1 : -1;
  213. });
  214. return sorted;
  215. }
  216. const Cell = styled('div')<{type?: string}>`
  217. display: flex;
  218. flex-direction: row;
  219. justify-content: ${p => (p.type === 'field' ? ' flex-end' : ' flex-start')};
  220. `;
  221. const StyledPanelTable = styled(PanelTable)<{borderless?: boolean}>`
  222. position: relative;
  223. display: grid;
  224. overflow: auto;
  225. margin: 0;
  226. margin-top: ${space(1.5)};
  227. border-radius: ${p => p.theme.borderRadius};
  228. font-size: ${p => p.theme.fontSizeMedium};
  229. box-shadow: none;
  230. ${p =>
  231. p.borderless &&
  232. `border-radius: 0 0 ${p.theme.borderRadius} ${p.theme.borderRadius};
  233. border-left: 0;
  234. border-right: 0;
  235. border-bottom: 0;`}
  236. ${PanelTableHeader} {
  237. height: min-content;
  238. }
  239. `;
  240. const HeaderCell = styled('div')<{disabled: boolean; type?: string}>`
  241. padding: 0 ${space(0.5)};
  242. display: flex;
  243. flex-direction: row;
  244. align-items: stretch;
  245. gap: ${space(0.5)};
  246. cursor: ${p => (p.disabled ? 'default' : 'pointer')};
  247. justify-content: ${p => (p.type === 'field' ? ' flex-end' : ' flex-start')};
  248. `;
  249. export const TableCell = styled(Cell)<{noValue?: boolean}>`
  250. padding: ${space(1)} ${space(3)};
  251. ${p => p.noValue && `color: ${p.theme.gray300};`}
  252. `;