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