pageSamplePerformanceTable.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import {useMemo, useState} from 'react';
  2. import {Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  5. import {Button, LinkButton} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import SearchBar from 'sentry/components/events/searchBar';
  8. import type {GridColumnHeader, GridColumnOrder} from 'sentry/components/gridEditable';
  9. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  10. import SortLink from 'sentry/components/gridEditable/sortLink';
  11. import ExternalLink from 'sentry/components/links/externalLink';
  12. import Pagination from 'sentry/components/pagination';
  13. import {SegmentedControl} from 'sentry/components/segmentedControl';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconChevron, IconPlay, IconProfiling} from 'sentry/icons';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {defined} from 'sentry/utils';
  19. import type {Sort} from 'sentry/utils/discover/fields';
  20. import {generateEventSlug} from 'sentry/utils/discover/urls';
  21. import {getShortEventId} from 'sentry/utils/events';
  22. import {getDuration} from 'sentry/utils/formatters';
  23. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  24. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  25. import {decodeScalar} from 'sentry/utils/queryString';
  26. import {useLocation} from 'sentry/utils/useLocation';
  27. import useOrganization from 'sentry/utils/useOrganization';
  28. import useProjects from 'sentry/utils/useProjects';
  29. import useRouter from 'sentry/utils/useRouter';
  30. import {useRoutes} from 'sentry/utils/useRoutes';
  31. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  32. import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery';
  33. import type {
  34. InteractionSpanSampleRowWithScore,
  35. TransactionSampleRowWithScore,
  36. } from 'sentry/views/performance/browser/webVitals/utils/types';
  37. import {
  38. DEFAULT_INDEXED_SORT,
  39. SORTABLE_INDEXED_FIELDS,
  40. SORTABLE_INDEXED_SCORE_FIELDS,
  41. } from 'sentry/views/performance/browser/webVitals/utils/types';
  42. import {useReplaceFidWithInpSetting} from 'sentry/views/performance/browser/webVitals/utils/useReplaceFidWithInpSetting';
  43. import {useStoredScoresSetting} from 'sentry/views/performance/browser/webVitals/utils/useStoredScoresSetting';
  44. import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort';
  45. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  46. type Column = GridColumnHeader<keyof TransactionSampleRowWithScore>;
  47. type InteractionsColumn = GridColumnHeader<keyof InteractionSpanSampleRowWithScore>;
  48. const PAGELOADS_COLUMN_ORDER: GridColumnOrder<keyof TransactionSampleRowWithScore>[] = [
  49. {key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'},
  50. {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
  51. {key: 'measurements.lcp', width: COL_WIDTH_UNDEFINED, name: 'LCP'},
  52. {key: 'measurements.fcp', width: COL_WIDTH_UNDEFINED, name: 'FCP'},
  53. {key: 'measurements.fid', width: COL_WIDTH_UNDEFINED, name: 'FID'},
  54. {key: 'measurements.cls', width: COL_WIDTH_UNDEFINED, name: 'CLS'},
  55. {key: 'measurements.ttfb', width: COL_WIDTH_UNDEFINED, name: 'TTFB'},
  56. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  57. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
  58. {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  59. ];
  60. const INTERACTION_SAMPLES_COLUMN_ORDER: GridColumnOrder<
  61. keyof InteractionSpanSampleRowWithScore
  62. >[] = [
  63. {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
  64. {key: 'measurements.inp', width: COL_WIDTH_UNDEFINED, name: 'INP'},
  65. {key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
  66. {key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
  67. {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  68. ];
  69. const INP_SEARCH_FILTER = 'has:measurements.fid (has:profile.id OR has:replayId)';
  70. enum Dataset {
  71. PAGELOADS = 'pageloads',
  72. INTERACTIONS = 'interactions',
  73. }
  74. type Props = {
  75. transaction: string;
  76. limit?: number;
  77. search?: string;
  78. };
  79. export function PageSamplePerformanceTable({transaction, search, limit = 9}: Props) {
  80. const location = useLocation();
  81. const {projects} = useProjects();
  82. const organization = useOrganization();
  83. const routes = useRoutes();
  84. const router = useRouter();
  85. const shouldUseStoredScores = useStoredScoresSetting();
  86. const shouldReplaceFidWithInp = useReplaceFidWithInpSetting();
  87. const [dataset, setDataset] = useState(Dataset.PAGELOADS);
  88. const samplesColumnOrder = useMemo(() => {
  89. if (shouldReplaceFidWithInp) {
  90. return PAGELOADS_COLUMN_ORDER.filter(col => col.key !== 'measurements.fid');
  91. }
  92. return PAGELOADS_COLUMN_ORDER;
  93. }, [shouldReplaceFidWithInp]);
  94. const sortableFields = shouldUseStoredScores
  95. ? SORTABLE_INDEXED_FIELDS
  96. : SORTABLE_INDEXED_FIELDS.filter(
  97. field => !SORTABLE_INDEXED_SCORE_FIELDS.includes(field)
  98. );
  99. let sort = useWebVitalsSort({
  100. defaultSort: DEFAULT_INDEXED_SORT,
  101. sortableFields: sortableFields as unknown as string[],
  102. });
  103. // Need to map fid back to inp for rendering
  104. if (shouldReplaceFidWithInp && sort.field === 'measurements.fid') {
  105. sort = {...sort, field: 'measurements.inp'};
  106. }
  107. const replayLinkGenerator = generateReplayLink(routes);
  108. const project = useMemo(
  109. () => projects.find(p => p.id === String(location.query.project)),
  110. [projects, location.query.project]
  111. );
  112. const query = decodeScalar(location.query.query);
  113. const {
  114. data: tableData,
  115. isLoading,
  116. pageLinks,
  117. } = useTransactionSamplesWebVitalsQuery({
  118. limit,
  119. transaction,
  120. query: search,
  121. withProfiles: true,
  122. enabled: dataset === Dataset.PAGELOADS,
  123. });
  124. const {
  125. data: interactionsTableData,
  126. isLoading: isInteractionsLoading,
  127. pageLinks: interactionsPageLinks,
  128. } = useTransactionSamplesWebVitalsQuery({
  129. limit,
  130. transaction,
  131. query: `${INP_SEARCH_FILTER} ${search ?? ''}`,
  132. withProfiles: true,
  133. enabled: dataset === Dataset.INTERACTIONS,
  134. });
  135. const getFormattedDuration = (value: number) => {
  136. return getDuration(value, value < 1 ? 0 : 2, true);
  137. };
  138. function renderHeadCell(col: Column | InteractionsColumn) {
  139. function generateSortLink() {
  140. const key = col.key === 'totalScore' ? 'measurements.score.total' : col.key;
  141. let newSortDirection: Sort['kind'] = 'desc';
  142. if (sort?.field === key) {
  143. if (sort.kind === 'desc') {
  144. newSortDirection = 'asc';
  145. }
  146. }
  147. const newSort = `${newSortDirection === 'desc' ? '-' : ''}${key}`;
  148. return {
  149. ...location,
  150. query: {...location.query, sort: newSort},
  151. };
  152. }
  153. const canSort = (sortableFields as ReadonlyArray<string>).includes(col.key);
  154. if (
  155. [
  156. 'measurements.fcp',
  157. 'measurements.lcp',
  158. 'measurements.ttfb',
  159. 'measurements.fid',
  160. 'measurements.cls',
  161. 'measurements.inp',
  162. 'transaction.duration',
  163. ].includes(col.key)
  164. ) {
  165. if (canSort) {
  166. return (
  167. <SortLink
  168. align="right"
  169. title={col.name}
  170. direction={sort?.field === col.key ? sort.kind : undefined}
  171. canSort={canSort}
  172. generateSortLink={generateSortLink}
  173. />
  174. );
  175. }
  176. return (
  177. <AlignRight>
  178. <span>{col.name}</span>
  179. </AlignRight>
  180. );
  181. }
  182. if (col.key === 'totalScore') {
  183. return (
  184. <SortLink
  185. title={
  186. <AlignCenter>
  187. <StyledTooltip
  188. isHoverable
  189. title={
  190. <span>
  191. {t('The overall performance rating of this page.')}
  192. <br />
  193. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
  194. {t('How is this calculated?')}
  195. </ExternalLink>
  196. </span>
  197. }
  198. >
  199. <TooltipHeader>{t('Perf Score')}</TooltipHeader>
  200. </StyledTooltip>
  201. </AlignCenter>
  202. }
  203. direction={sort?.field === col.key ? sort.kind : undefined}
  204. canSort={canSort}
  205. generateSortLink={generateSortLink}
  206. align={undefined}
  207. />
  208. );
  209. }
  210. if (col.key === 'replayId' || col.key === 'profile.id') {
  211. return (
  212. <AlignCenter>
  213. <span>{col.name}</span>
  214. </AlignCenter>
  215. );
  216. }
  217. return <span>{col.name}</span>;
  218. }
  219. function renderBodyCell(
  220. col: Column | InteractionsColumn,
  221. row: TransactionSampleRowWithScore | InteractionSpanSampleRowWithScore
  222. ) {
  223. const {key} = col;
  224. if (key === 'totalScore') {
  225. return (
  226. <AlignCenter>
  227. <PerformanceBadge score={row.totalScore} />
  228. </AlignCenter>
  229. );
  230. }
  231. if (key === 'transaction' && 'transaction' in row) {
  232. return (
  233. <NoOverflow>
  234. {project && (
  235. <StyledProjectAvatar
  236. project={project}
  237. direction="left"
  238. size={16}
  239. hasTooltip
  240. tooltip={project.slug}
  241. />
  242. )}
  243. <Link
  244. to={{...location, query: {...location.query, transaction: row.transaction}}}
  245. >
  246. {row.transaction}
  247. </Link>
  248. </NoOverflow>
  249. );
  250. }
  251. if (
  252. [
  253. 'measurements.fcp',
  254. 'measurements.lcp',
  255. 'measurements.ttfb',
  256. 'measurements.fid',
  257. 'measurements.inp',
  258. 'transaction.duration',
  259. ].includes(key)
  260. ) {
  261. return (
  262. <AlignRight>
  263. {row[key] === undefined ? (
  264. <NoValue>{' \u2014 '}</NoValue>
  265. ) : (
  266. getFormattedDuration((row[key] as number) / 1000)
  267. )}
  268. </AlignRight>
  269. );
  270. }
  271. if (['measurements.cls', 'opportunity'].includes(key)) {
  272. return (
  273. <AlignRight>
  274. {row[key] === undefined ? (
  275. <NoValue>{' \u2014 '}</NoValue>
  276. ) : (
  277. Math.round((row[key] as number) * 100) / 100
  278. )}
  279. </AlignRight>
  280. );
  281. }
  282. if (key === 'profile.id') {
  283. const profileTarget =
  284. defined(row.projectSlug) && defined(row['profile.id'])
  285. ? generateProfileFlamechartRoute({
  286. orgSlug: organization.slug,
  287. projectSlug: row.projectSlug,
  288. profileId: String(row['profile.id']),
  289. })
  290. : null;
  291. return (
  292. <NoOverflow>
  293. <AlignCenter>
  294. {profileTarget && (
  295. <Tooltip title={t('View Profile')}>
  296. <LinkButton to={profileTarget} size="xs">
  297. <IconProfiling size="xs" />
  298. </LinkButton>
  299. </Tooltip>
  300. )}
  301. </AlignCenter>
  302. </NoOverflow>
  303. );
  304. }
  305. if (key === 'replayId' && 'id' in row) {
  306. const replayTarget =
  307. row['transaction.duration'] !== undefined &&
  308. replayLinkGenerator(
  309. organization,
  310. {
  311. replayId: row.replayId,
  312. id: row.id,
  313. 'transaction.duration': row['transaction.duration'],
  314. timestamp: row.timestamp,
  315. },
  316. undefined
  317. );
  318. return (
  319. <NoOverflow>
  320. <AlignCenter>
  321. {replayTarget && Object.keys(replayTarget).length > 0 && (
  322. <Tooltip title={t('View Replay')}>
  323. <LinkButton to={replayTarget} size="xs">
  324. <IconPlay size="xs" />
  325. </LinkButton>
  326. </Tooltip>
  327. )}
  328. </AlignCenter>
  329. </NoOverflow>
  330. );
  331. }
  332. if (key === 'id' && 'id' in row) {
  333. const eventSlug = generateEventSlug({...row, project: row.projectSlug});
  334. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  335. return (
  336. <NoOverflow>
  337. <Tooltip title={t('View Transaction')}>
  338. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  339. </Tooltip>
  340. </NoOverflow>
  341. );
  342. }
  343. return <NoOverflow>{row[key]}</NoOverflow>;
  344. }
  345. return (
  346. <span>
  347. <SearchBarContainer>
  348. {shouldReplaceFidWithInp && (
  349. <SegmentedControl
  350. size="md"
  351. value={dataset}
  352. onChange={newDataSet => {
  353. // Reset pagination and sort when switching datasets
  354. router.replace({
  355. ...location,
  356. query: {...location.query, sort: undefined, cursor: undefined},
  357. });
  358. setDataset(newDataSet);
  359. }}
  360. >
  361. <SegmentedControl.Item key={Dataset.PAGELOADS}>
  362. {t('Pageloads')}
  363. </SegmentedControl.Item>
  364. <SegmentedControl.Item key={Dataset.INTERACTIONS}>
  365. {t('Interactions')}
  366. </SegmentedControl.Item>
  367. </SegmentedControl>
  368. )}
  369. <StyledSearchBar
  370. query={query}
  371. organization={organization}
  372. onSearch={queryString =>
  373. router.replace({
  374. ...location,
  375. query: {...location.query, query: queryString},
  376. })
  377. }
  378. />
  379. <StyledPagination
  380. pageLinks={dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks}
  381. disabled={dataset === Dataset.INTERACTIONS ? isInteractionsLoading : isLoading}
  382. size="md"
  383. />
  384. {/* The Pagination component disappears if pageLinks is not defined,
  385. which happens any time the table data is loading. So we render a
  386. disabled button bar if pageLinks is not defined to minimize ui shifting */}
  387. {!(dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks) && (
  388. <Wrapper>
  389. <ButtonBar merged>
  390. <Button
  391. icon={<IconChevron direction="left" />}
  392. disabled
  393. aria-label={t('Previous')}
  394. />
  395. <Button
  396. icon={<IconChevron direction="right" />}
  397. disabled
  398. aria-label={t('Next')}
  399. />
  400. </ButtonBar>
  401. </Wrapper>
  402. )}
  403. </SearchBarContainer>
  404. <GridContainer>
  405. {dataset === Dataset.PAGELOADS && (
  406. <GridEditable
  407. isLoading={isLoading}
  408. columnOrder={samplesColumnOrder}
  409. columnSortBy={[]}
  410. data={tableData}
  411. grid={{
  412. renderHeadCell,
  413. renderBodyCell,
  414. }}
  415. location={location}
  416. minimumColWidth={70}
  417. />
  418. )}
  419. {dataset === Dataset.INTERACTIONS && (
  420. <GridEditable
  421. isLoading={isInteractionsLoading}
  422. columnOrder={INTERACTION_SAMPLES_COLUMN_ORDER}
  423. columnSortBy={[]}
  424. data={interactionsTableData as unknown as InteractionSpanSampleRowWithScore[]}
  425. grid={{
  426. renderHeadCell,
  427. renderBodyCell,
  428. }}
  429. location={location}
  430. minimumColWidth={70}
  431. />
  432. )}
  433. </GridContainer>
  434. </span>
  435. );
  436. }
  437. const NoOverflow = styled('span')`
  438. overflow: hidden;
  439. text-overflow: ellipsis;
  440. white-space: nowrap;
  441. `;
  442. const AlignRight = styled('span')<{color?: string}>`
  443. text-align: right;
  444. width: 100%;
  445. ${p => (p.color ? `color: ${p.color};` : '')}
  446. `;
  447. const AlignCenter = styled('div')`
  448. display: block;
  449. margin: auto;
  450. text-align: center;
  451. width: 100%;
  452. `;
  453. const StyledProjectAvatar = styled(ProjectAvatar)`
  454. top: ${space(0.25)};
  455. position: relative;
  456. padding-right: ${space(1)};
  457. `;
  458. // Not pretty but we need to override gridEditable styles since the original
  459. // styles have too much padding for small spaces
  460. const GridContainer = styled('div')`
  461. margin-bottom: ${space(1)};
  462. th {
  463. padding: 0 ${space(1)};
  464. }
  465. th:first-child {
  466. padding-left: ${space(2)};
  467. }
  468. th:last-child {
  469. padding-right: ${space(2)};
  470. }
  471. td {
  472. padding: ${space(1)};
  473. }
  474. td:first-child {
  475. padding-right: ${space(1)};
  476. padding-left: ${space(2)};
  477. }
  478. `;
  479. const NoValue = styled('span')`
  480. color: ${p => p.theme.gray300};
  481. `;
  482. const SearchBarContainer = styled('div')`
  483. display: flex;
  484. margin-top: ${space(2)};
  485. margin-bottom: ${space(1)};
  486. gap: ${space(1)};
  487. `;
  488. const StyledSearchBar = styled(SearchBar)`
  489. flex-grow: 1;
  490. `;
  491. const StyledPagination = styled(Pagination)`
  492. margin: 0;
  493. `;
  494. const Wrapper = styled('div')`
  495. display: flex;
  496. align-items: center;
  497. justify-content: flex-end;
  498. margin: 0;
  499. `;
  500. const TooltipHeader = styled('span')`
  501. ${p => p.theme.tooltipUnderline()};
  502. `;
  503. const StyledTooltip = styled(Tooltip)`
  504. top: 1px;
  505. position: relative;
  506. `;