summaryTable.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import {Fragment, memo, useCallback} from 'react';
  2. import styled from '@emotion/styled';
  3. import colorFn from 'color';
  4. import {Button, LinkButton} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  7. import type {Series} from 'sentry/components/metrics/chart/types';
  8. import TextOverflow from 'sentry/components/textOverflow';
  9. import {Tooltip} from 'sentry/components/tooltip';
  10. import {IconArrow, IconFilter, IconLightning, IconReleases} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {MetricAggregation} from 'sentry/types/metrics';
  14. import {trackAnalytics} from 'sentry/utils/analytics';
  15. import {getUtcDateString} from 'sentry/utils/dates';
  16. import {DEFAULT_SORT_STATE} from 'sentry/utils/metrics/constants';
  17. import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
  18. import {
  19. type FocusedMetricsSeries,
  20. MetricSeriesFilterUpdateType,
  21. type SortState,
  22. } from 'sentry/utils/metrics/types';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import usePageFilters from 'sentry/utils/usePageFilters';
  25. import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
  26. export const SummaryTable = memo(function SummaryTable({
  27. series,
  28. onRowClick,
  29. onColorDotClick,
  30. onSortChange,
  31. sort = DEFAULT_SORT_STATE,
  32. onRowHover,
  33. onRowFilter,
  34. }: {
  35. onRowClick: (series: FocusedMetricsSeries) => void;
  36. onSortChange: (sortState: SortState) => void;
  37. series: Series[];
  38. onColorDotClick?: (series: FocusedMetricsSeries) => void;
  39. onRowFilter?: (
  40. index: number,
  41. series: FocusedMetricsSeries,
  42. updateType: MetricSeriesFilterUpdateType
  43. ) => void;
  44. onRowHover?: (seriesName: string) => void;
  45. sort?: SortState;
  46. }) {
  47. const {selection} = usePageFilters();
  48. const organization = useOrganization();
  49. const totalColumns = getTotalColumns(series);
  50. const canFilter = series.length > 1 && !!onRowFilter;
  51. const hasActions = series.some(s => s.release || s.transaction) || canFilter;
  52. const hasMultipleSeries = series.length > 1;
  53. const changeSort = useCallback(
  54. (name: SortState['name']) => {
  55. trackAnalytics('ddm.widget.sort', {
  56. organization,
  57. by: name ?? '(none)',
  58. order: sort.order,
  59. });
  60. if (sort.name === name) {
  61. if (sort.order === 'desc') {
  62. onSortChange(DEFAULT_SORT_STATE);
  63. } else if (sort.order === 'asc') {
  64. onSortChange({
  65. name,
  66. order: 'desc',
  67. });
  68. } else {
  69. onSortChange({
  70. name,
  71. order: 'asc',
  72. });
  73. }
  74. } else {
  75. onSortChange({
  76. name,
  77. order: 'asc',
  78. });
  79. }
  80. },
  81. [sort, onSortChange, organization]
  82. );
  83. const handleRowFilter = useCallback(
  84. (
  85. index: number | undefined,
  86. row: FocusedMetricsSeries,
  87. updateType: MetricSeriesFilterUpdateType
  88. ) => {
  89. if (index === undefined) {
  90. return;
  91. }
  92. trackAnalytics('ddm.widget.add_row_filter', {
  93. organization,
  94. });
  95. onRowFilter?.(index, row, updateType);
  96. },
  97. [onRowFilter, organization]
  98. );
  99. const releaseTo = (release: string) => {
  100. return {
  101. pathname: `/organizations/${organization.slug}/releases/${encodeURIComponent(
  102. release
  103. )}/`,
  104. query: {
  105. pageStart: selection.datetime.start,
  106. pageEnd: selection.datetime.end,
  107. pageStatsPeriod: selection.datetime.period,
  108. project: selection.projects,
  109. environment: selection.environments,
  110. },
  111. };
  112. };
  113. const transactionTo = (transaction: string) =>
  114. transactionSummaryRouteWithQuery({
  115. organization,
  116. transaction,
  117. projectID: selection.projects.map(p => String(p)),
  118. query: {
  119. query: '',
  120. environment: selection.environments,
  121. start: selection.datetime.start
  122. ? getUtcDateString(selection.datetime.start)
  123. : undefined,
  124. end: selection.datetime.end
  125. ? getUtcDateString(selection.datetime.end)
  126. : undefined,
  127. statsPeriod: selection.datetime.period,
  128. },
  129. });
  130. const rows = series
  131. .map(s => {
  132. return {
  133. ...s,
  134. ...getTotals(s),
  135. };
  136. })
  137. .sort((a, b) => {
  138. const {name, order} = sort;
  139. if (!name) {
  140. return 0;
  141. }
  142. if (name === 'name') {
  143. return order === 'asc'
  144. ? a.seriesName.localeCompare(b.seriesName)
  145. : b.seriesName.localeCompare(a.seriesName);
  146. }
  147. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  148. const aValue = a[name] ?? 0;
  149. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  150. const bValue = b[name] ?? 0;
  151. return order === 'asc' ? aValue - bValue : bValue - aValue;
  152. });
  153. // We do not want to render the table if there is no data to display
  154. // If the data is being loaded, then the whole chart will be in a loading state and this is being handled by the parent component
  155. if (!rows.length) {
  156. return null;
  157. }
  158. return (
  159. <SummaryTableWrapper
  160. hasActions={hasActions}
  161. totalColumnsCount={totalColumns.length}
  162. data-test-id="summary-table"
  163. >
  164. <HeaderCell disabled />
  165. <HeaderCell disabled />
  166. <SortableHeaderCell onClick={changeSort} sortState={sort} name="name">
  167. {t('Name')}
  168. </SortableHeaderCell>
  169. {totalColumns.map(aggregate => (
  170. <SortableHeaderCell
  171. key={aggregate}
  172. onClick={changeSort}
  173. sortState={sort}
  174. name={aggregate}
  175. right
  176. >
  177. {aggregate}
  178. </SortableHeaderCell>
  179. ))}
  180. {hasActions && <HeaderCell disabled right />}
  181. <HeaderCell disabled />
  182. <TableBodyWrapper
  183. hasActions={hasActions}
  184. onMouseLeave={() => {
  185. if (hasMultipleSeries) {
  186. onRowHover?.('');
  187. }
  188. }}
  189. >
  190. {rows.map(row => {
  191. return (
  192. <Fragment key={row.id}>
  193. <Row
  194. onClick={() => {
  195. if (hasMultipleSeries) {
  196. onRowClick({id: row.id, groupBy: row.groupBy});
  197. }
  198. }}
  199. onMouseEnter={() => {
  200. if (hasMultipleSeries) {
  201. onRowHover?.(row.id);
  202. }
  203. }}
  204. >
  205. <PaddingCell />
  206. <Cell
  207. onClick={event => {
  208. event.stopPropagation();
  209. if (hasMultipleSeries) {
  210. onColorDotClick?.(row);
  211. }
  212. }}
  213. >
  214. <ColorDot
  215. color={row.color}
  216. isHidden={!!row.hidden}
  217. style={{
  218. backgroundColor: row.hidden
  219. ? 'transparent'
  220. : colorFn(row.color).alpha(1).string(),
  221. }}
  222. />
  223. </Cell>
  224. <TextOverflowCell>
  225. <Tooltip
  226. title={
  227. <FullSeriesName seriesName={row.seriesName} groupBy={row.groupBy} />
  228. }
  229. delay={500}
  230. overlayStyle={{maxWidth: '80vw'}}
  231. >
  232. <TextOverflow>{row.seriesName}</TextOverflow>
  233. </Tooltip>
  234. </TextOverflowCell>
  235. {totalColumns.map(aggregate => (
  236. <NumberCell key={aggregate}>
  237. {row[aggregate as keyof typeof row]
  238. ? formatMetricUsingUnit(
  239. row[aggregate as keyof typeof row] as number | null,
  240. row.unit
  241. )
  242. : '\u2014'}
  243. </NumberCell>
  244. ))}
  245. {hasActions && (
  246. <CenterCell>
  247. <ButtonBar gap={0.5}>
  248. {row.transaction && (
  249. <div>
  250. <Tooltip title={t('Open Transaction Summary')}>
  251. <LinkButton
  252. to={transactionTo(row.transaction)}
  253. size="zero"
  254. borderless
  255. >
  256. <IconLightning size="sm" />
  257. </LinkButton>
  258. </Tooltip>
  259. </div>
  260. )}
  261. {row.release && (
  262. <div>
  263. <Tooltip title={t('Open Release Details')}>
  264. <LinkButton
  265. to={releaseTo(row.release)}
  266. size="zero"
  267. borderless
  268. >
  269. <IconReleases size="sm" />
  270. </LinkButton>
  271. </Tooltip>
  272. </div>
  273. )}
  274. {/* do not show add/exclude filter if there's no groupby or if this is an equation */}
  275. {Object.keys(row.groupBy ?? {}).length > 0 &&
  276. !row.isEquationSeries && (
  277. <DropdownMenu
  278. items={[
  279. {
  280. key: 'add-to-filter',
  281. label: t('Add to filter'),
  282. size: 'sm',
  283. onAction: () => {
  284. handleRowFilter(
  285. row.queryIndex,
  286. row,
  287. MetricSeriesFilterUpdateType.ADD
  288. );
  289. },
  290. },
  291. {
  292. key: 'exclude-from-filter',
  293. label: t('Exclude from filter'),
  294. size: 'sm',
  295. onAction: () => {
  296. handleRowFilter(
  297. row.queryIndex,
  298. row,
  299. MetricSeriesFilterUpdateType.EXCLUDE
  300. );
  301. },
  302. },
  303. ]}
  304. trigger={triggerProps => (
  305. <Button
  306. {...triggerProps}
  307. aria-label={t('Quick Context Action Menu')}
  308. data-test-id="quick-context-action-trigger"
  309. borderless
  310. size="zero"
  311. onClick={e => {
  312. e.stopPropagation();
  313. e.preventDefault();
  314. triggerProps.onClick?.(e);
  315. }}
  316. icon={<IconFilter size="sm" />}
  317. />
  318. )}
  319. />
  320. )}
  321. </ButtonBar>
  322. </CenterCell>
  323. )}
  324. <PaddingCell />
  325. </Row>
  326. </Fragment>
  327. );
  328. })}
  329. </TableBodyWrapper>
  330. </SummaryTableWrapper>
  331. );
  332. });
  333. function FullSeriesName({
  334. seriesName,
  335. groupBy,
  336. }: {
  337. seriesName: string;
  338. groupBy?: Record<string, string>;
  339. }) {
  340. if (!groupBy || Object.keys(groupBy).length === 0) {
  341. return <Fragment>{seriesName}</Fragment>;
  342. }
  343. const goupByEntries = Object.entries(groupBy);
  344. return (
  345. <Fragment>
  346. {goupByEntries.map(([key, value], index) => {
  347. const formattedValue = value || t('(none)');
  348. return (
  349. <span key={key}>
  350. <strong>{`${key}:`}</strong>
  351. &nbsp;
  352. {index === goupByEntries.length - 1 ? formattedValue : `${formattedValue}, `}
  353. </span>
  354. );
  355. })}
  356. </Fragment>
  357. );
  358. }
  359. function SortableHeaderCell({
  360. sortState,
  361. name,
  362. right,
  363. children,
  364. onClick,
  365. }: {
  366. children: React.ReactNode;
  367. name: SortState['name'];
  368. onClick: (name: SortState['name']) => void;
  369. sortState: SortState;
  370. right?: boolean;
  371. }) {
  372. const sortIcon =
  373. sortState.name === name ? (
  374. <IconArrow size="xs" direction={sortState.order === 'asc' ? 'up' : 'down'} />
  375. ) : (
  376. ''
  377. );
  378. if (right) {
  379. return (
  380. <HeaderCell
  381. onClick={() => {
  382. onClick(name);
  383. }}
  384. right
  385. >
  386. {sortIcon} {children}
  387. </HeaderCell>
  388. );
  389. }
  390. return (
  391. <HeaderCell
  392. onClick={() => {
  393. onClick(name);
  394. }}
  395. >
  396. {children} {sortIcon}
  397. </HeaderCell>
  398. );
  399. }
  400. // These aggregates can always be shown as we can calculate them on the frontend
  401. const DEFAULT_TOTALS: MetricAggregation[] = ['avg', 'min', 'max', 'sum'];
  402. // Count and count_unique will always match the sum column
  403. const TOTALS_BLOCKLIST: MetricAggregation[] = ['count', 'count_unique'];
  404. function getTotalColumns(series: Series[]) {
  405. const totals = new Set<MetricAggregation>();
  406. series.forEach(({aggregate}) => {
  407. if (!DEFAULT_TOTALS.includes(aggregate) && !TOTALS_BLOCKLIST.includes(aggregate)) {
  408. totals.add(aggregate);
  409. }
  410. });
  411. return DEFAULT_TOTALS.concat(Array.from(totals).sort((a, b) => a.localeCompare(b)));
  412. }
  413. function getTotals(series: Series) {
  414. const {data, total, aggregate} = series;
  415. if (!data) {
  416. return {min: null, max: null, avg: null, sum: null};
  417. }
  418. const res = data.reduce(
  419. (acc, {value}) => {
  420. if (value === null) {
  421. return acc;
  422. }
  423. acc.min = Math.min(acc.min, value);
  424. acc.max = Math.max(acc.max, value);
  425. acc.sum += value;
  426. acc.definedDatapoints += 1;
  427. return acc;
  428. },
  429. {min: Infinity, max: -Infinity, sum: 0, definedDatapoints: 0}
  430. );
  431. const values: Partial<Record<MetricAggregation, number>> = {
  432. min: res.min,
  433. max: res.max,
  434. sum: res.sum,
  435. avg: res.sum / res.definedDatapoints,
  436. };
  437. values[aggregate] = total;
  438. return values;
  439. }
  440. const SummaryTableWrapper = styled(`div`)<{
  441. hasActions: boolean;
  442. totalColumnsCount: number;
  443. }>`
  444. display: grid;
  445. /* padding | color dot | name | avg | min | max | sum | total | actions | padding */
  446. grid-template-columns:
  447. ${space(0.75)} ${space(3)} 8fr repeat(
  448. ${p => (p.hasActions ? p.totalColumnsCount + 1 : p.totalColumnsCount)},
  449. max-content
  450. )
  451. ${space(0.75)};
  452. max-height: 200px;
  453. overflow-x: hidden;
  454. overflow-y: auto;
  455. border: 1px solid ${p => p.theme.border};
  456. border-radius: ${p => p.theme.borderRadius};
  457. font-size: ${p => p.theme.fontSizeSmall};
  458. `;
  459. const TableBodyWrapper = styled(`div`)<{hasActions: boolean}>`
  460. display: contents;
  461. `;
  462. const HeaderCell = styled('div')<{disabled?: boolean; right?: boolean}>`
  463. display: flex;
  464. flex-direction: row;
  465. text-transform: uppercase;
  466. justify-content: ${p => (p.right ? 'flex-end' : 'flex-start')};
  467. align-items: center;
  468. gap: ${space(0.5)};
  469. padding: ${space(0.25)} ${space(0.75)};
  470. line-height: ${p => p.theme.text.lineHeightBody};
  471. font-weight: ${p => p.theme.fontWeightBold};
  472. font-family: ${p => p.theme.text.family};
  473. color: ${p => p.theme.subText};
  474. user-select: none;
  475. background-color: ${p => p.theme.backgroundSecondary};
  476. border-radius: 0;
  477. border-bottom: 1px solid ${p => p.theme.border};
  478. top: 0;
  479. position: sticky;
  480. z-index: 1;
  481. &:hover {
  482. cursor: ${p => (p.disabled ? 'default' : 'pointer')};
  483. }
  484. `;
  485. const Cell = styled('div')<{right?: boolean}>`
  486. display: flex;
  487. padding: ${space(0.25)} ${space(0.75)};
  488. align-items: center;
  489. justify-content: flex-start;
  490. white-space: nowrap;
  491. `;
  492. const NumberCell = styled(Cell)`
  493. justify-content: flex-end;
  494. font-variant-numeric: tabular-nums;
  495. `;
  496. const CenterCell = styled(Cell)`
  497. justify-content: center;
  498. `;
  499. const TextOverflowCell = styled(Cell)`
  500. min-width: 0;
  501. `;
  502. const ColorDot = styled(`div`)<{color: string; isHidden: boolean}>`
  503. border: 1px solid ${p => p.color};
  504. border-radius: 50%;
  505. width: ${space(1)};
  506. height: ${space(1)};
  507. `;
  508. const PaddingCell = styled(Cell)`
  509. padding: 0;
  510. `;
  511. const Row = styled('div')`
  512. display: contents;
  513. &:hover {
  514. cursor: pointer;
  515. ${Cell}, ${NumberCell}, ${CenterCell}, ${PaddingCell}, ${TextOverflowCell} {
  516. background-color: ${p => p.theme.bodyBackground};
  517. }
  518. }
  519. `;