table.tsx 7.8 KB

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