summaryTable.tsx 16 KB

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