pageSamplePerformanceTable.tsx 17 KB

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