pageSamplePerformanceTable.tsx 17 KB

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