pageSamplePerformanceTable.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. import {useMemo} 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 GridEditable, {
  9. COL_WIDTH_UNDEFINED,
  10. GridColumnHeader,
  11. GridColumnOrder,
  12. } from 'sentry/components/gridEditable';
  13. import SortLink from 'sentry/components/gridEditable/sortLink';
  14. import ExternalLink from 'sentry/components/links/externalLink';
  15. import Pagination from 'sentry/components/pagination';
  16. import {Tooltip} from 'sentry/components/tooltip';
  17. import {IconChevron, IconPlay, IconProfiling} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import {defined} from 'sentry/utils';
  21. import {Sort} from 'sentry/utils/discover/fields';
  22. import {generateEventSlug} from 'sentry/utils/discover/urls';
  23. import {getShortEventId} from 'sentry/utils/events';
  24. import {getDuration} from 'sentry/utils/formatters';
  25. import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
  26. import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
  27. import {decodeScalar} from 'sentry/utils/queryString';
  28. import {useLocation} from 'sentry/utils/useLocation';
  29. import useOrganization from 'sentry/utils/useOrganization';
  30. import useProjects from 'sentry/utils/useProjects';
  31. import useRouter from 'sentry/utils/useRouter';
  32. import {useRoutes} from 'sentry/utils/useRoutes';
  33. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  34. import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery';
  35. import {
  36. DEFAULT_INDEXED_SORT,
  37. SORTABLE_INDEXED_FIELDS,
  38. TransactionSampleRow,
  39. } from 'sentry/views/performance/browser/webVitals/utils/types';
  40. import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort';
  41. import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
  42. export type TransactionSampleRowWithScoreAndExtra = TransactionSampleRow & {
  43. score: number;
  44. };
  45. type Column = GridColumnHeader<keyof TransactionSampleRowWithScoreAndExtra>;
  46. export const COLUMN_ORDER: GridColumnOrder<
  47. keyof TransactionSampleRowWithScoreAndExtra
  48. >[] = [
  49. {key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
  50. {key: 'transaction.duration', width: COL_WIDTH_UNDEFINED, name: 'Duration'},
  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: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  57. ];
  58. type Props = {
  59. transaction: string;
  60. columnOrder?: GridColumnOrder<keyof TransactionSampleRowWithScoreAndExtra>[];
  61. limit?: number;
  62. search?: string;
  63. };
  64. export function PageSamplePerformanceTable({
  65. transaction,
  66. columnOrder,
  67. search,
  68. limit = 9,
  69. }: Props) {
  70. const location = useLocation();
  71. const {projects} = useProjects();
  72. const organization = useOrganization();
  73. const routes = useRoutes();
  74. const router = useRouter();
  75. const sort = useWebVitalsSort({
  76. defaultSort: DEFAULT_INDEXED_SORT,
  77. sortableFields: SORTABLE_INDEXED_FIELDS as unknown as string[],
  78. });
  79. const replayLinkGenerator = generateReplayLink(routes);
  80. const project = useMemo(
  81. () => projects.find(p => p.id === String(location.query.project)),
  82. [projects, location.query.project]
  83. );
  84. const query = decodeScalar(location.query.query);
  85. // Do 3 queries filtering on LCP to get a spread of good, meh, and poor events
  86. // We can't query by performance score yet, so we're using LCP as a best estimate
  87. const {data, isLoading, pageLinks} = useTransactionSamplesWebVitalsQuery({
  88. limit,
  89. transaction,
  90. query: search,
  91. withProfiles: true,
  92. });
  93. const tableData: TransactionSampleRowWithScoreAndExtra[] = data.map(row => ({
  94. ...row,
  95. view: null,
  96. }));
  97. const getFormattedDuration = (value: number) => {
  98. return getDuration(value, value < 1 ? 0 : 2, true);
  99. };
  100. function renderHeadCell(col: Column) {
  101. function generateSortLink() {
  102. let newSortDirection: Sort['kind'] = 'desc';
  103. if (sort?.field === col.key) {
  104. if (sort.kind === 'desc') {
  105. newSortDirection = 'asc';
  106. }
  107. }
  108. const newSort = `${newSortDirection === 'desc' ? '-' : ''}${col.key}`;
  109. return {
  110. ...location,
  111. query: {...location.query, sort: newSort},
  112. };
  113. }
  114. const canSort = (SORTABLE_INDEXED_FIELDS as ReadonlyArray<string>).includes(col.key);
  115. if (
  116. [
  117. 'measurements.fcp',
  118. 'measurements.lcp',
  119. 'measurements.ttfb',
  120. 'measurements.fid',
  121. 'measurements.cls',
  122. 'transaction.duration',
  123. ].includes(col.key)
  124. ) {
  125. if (canSort) {
  126. return (
  127. <SortLink
  128. align="right"
  129. title={col.name}
  130. direction={sort?.field === col.key ? sort.kind : undefined}
  131. canSort={canSort}
  132. generateSortLink={generateSortLink}
  133. />
  134. );
  135. }
  136. return (
  137. <AlignRight>
  138. <span>{col.name}</span>
  139. </AlignRight>
  140. );
  141. }
  142. if (col.key === 'score') {
  143. return (
  144. <AlignCenter>
  145. <StyledTooltip
  146. isHoverable
  147. title={
  148. <span>
  149. {t('The overall performance rating of this page.')}
  150. <br />
  151. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
  152. {t('How is this calculated?')}
  153. </ExternalLink>
  154. </span>
  155. }
  156. >
  157. <TooltipHeader>{t('Perf Score')}</TooltipHeader>
  158. </StyledTooltip>
  159. </AlignCenter>
  160. );
  161. }
  162. if (col.key === 'replayId' || col.key === 'profile.id') {
  163. return (
  164. <AlignCenter>
  165. <span>{col.name}</span>
  166. </AlignCenter>
  167. );
  168. }
  169. return <span>{col.name}</span>;
  170. }
  171. function renderBodyCell(col: Column, row: TransactionSampleRowWithScoreAndExtra) {
  172. const {key} = col;
  173. if (key === 'score') {
  174. return (
  175. <AlignCenter>
  176. <PerformanceBadge score={row.score} />
  177. </AlignCenter>
  178. );
  179. }
  180. if (key === 'transaction') {
  181. return (
  182. <NoOverflow>
  183. {project && (
  184. <StyledProjectAvatar
  185. project={project}
  186. direction="left"
  187. size={16}
  188. hasTooltip
  189. tooltip={project.slug}
  190. />
  191. )}
  192. <Link
  193. to={{...location, query: {...location.query, transaction: row.transaction}}}
  194. >
  195. {row.transaction}
  196. </Link>
  197. </NoOverflow>
  198. );
  199. }
  200. if (
  201. [
  202. 'measurements.fcp',
  203. 'measurements.lcp',
  204. 'measurements.ttfb',
  205. 'measurements.fid',
  206. 'transaction.duration',
  207. ].includes(key)
  208. ) {
  209. return (
  210. <AlignRight>
  211. {row[key] === null ? (
  212. <NoValue>{' \u2014 '}</NoValue>
  213. ) : (
  214. getFormattedDuration((row[key] as number) / 1000)
  215. )}
  216. </AlignRight>
  217. );
  218. }
  219. if (['measurements.cls', 'opportunity'].includes(key)) {
  220. return <AlignRight>{Math.round((row[key] as number) * 100) / 100}</AlignRight>;
  221. }
  222. if (key === 'profile.id') {
  223. const profileTarget =
  224. defined(row.projectSlug) && defined(row['profile.id'])
  225. ? generateProfileFlamechartRoute({
  226. orgSlug: organization.slug,
  227. projectSlug: row.projectSlug,
  228. profileId: String(row['profile.id']),
  229. })
  230. : null;
  231. return (
  232. <NoOverflow>
  233. <AlignCenter>
  234. {profileTarget && (
  235. <Tooltip title={t('View Profile')}>
  236. <LinkButton to={profileTarget} size="xs">
  237. <IconProfiling size="xs" />
  238. </LinkButton>
  239. </Tooltip>
  240. )}
  241. </AlignCenter>
  242. </NoOverflow>
  243. );
  244. }
  245. if (key === 'replayId') {
  246. const replayTarget =
  247. row['transaction.duration'] !== null &&
  248. replayLinkGenerator(
  249. organization,
  250. {
  251. replayId: row.replayId,
  252. id: row.id,
  253. 'transaction.duration': row['transaction.duration'],
  254. timestamp: row.timestamp,
  255. },
  256. undefined
  257. );
  258. return (
  259. <NoOverflow>
  260. <AlignCenter>
  261. {replayTarget && Object.keys(replayTarget).length > 0 && (
  262. <Tooltip title={t('View Replay')}>
  263. <LinkButton to={replayTarget} size="xs">
  264. <IconPlay size="xs" />
  265. </LinkButton>
  266. </Tooltip>
  267. )}
  268. </AlignCenter>
  269. </NoOverflow>
  270. );
  271. }
  272. if (key === 'id') {
  273. const eventSlug = generateEventSlug({...row, project: row.projectSlug});
  274. const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
  275. return (
  276. <NoOverflow>
  277. <Tooltip title={t('View Transaction')}>
  278. <Link to={eventTarget}>{getShortEventId(row.id)}</Link>
  279. </Tooltip>
  280. </NoOverflow>
  281. );
  282. }
  283. return <NoOverflow>{row[key]}</NoOverflow>;
  284. }
  285. return (
  286. <span>
  287. <SearchBarContainer>
  288. <StyledSearchBar
  289. query={query}
  290. organization={organization}
  291. onSearch={queryString =>
  292. router.replace({
  293. ...location,
  294. query: {...location.query, query: queryString},
  295. })
  296. }
  297. />
  298. <StyledPagination pageLinks={pageLinks} disabled={isLoading} size="md" />
  299. {/* The Pagination component disappears if pageLinks is not defined,
  300. which happens any time the table data is loading. So we render a
  301. disabled button bar if pageLinks is not defined to minimize ui shifting */}
  302. {!pageLinks && (
  303. <Wrapper>
  304. <ButtonBar merged>
  305. <Button
  306. icon={<IconChevron direction="left" size="sm" />}
  307. size="md"
  308. disabled
  309. aria-label={t('Previous')}
  310. />
  311. <Button
  312. icon={<IconChevron direction="right" size="sm" />}
  313. size="md"
  314. disabled
  315. aria-label={t('Next')}
  316. />
  317. </ButtonBar>
  318. </Wrapper>
  319. )}
  320. </SearchBarContainer>
  321. <GridContainer>
  322. <GridEditable
  323. isLoading={isLoading}
  324. columnOrder={columnOrder ?? COLUMN_ORDER}
  325. columnSortBy={[]}
  326. data={tableData}
  327. grid={{
  328. renderHeadCell,
  329. renderBodyCell,
  330. }}
  331. location={location}
  332. minimumColWidth={70}
  333. />
  334. </GridContainer>
  335. </span>
  336. );
  337. }
  338. const NoOverflow = styled('span')`
  339. overflow: hidden;
  340. text-overflow: ellipsis;
  341. white-space: nowrap;
  342. `;
  343. const AlignRight = styled('span')<{color?: string}>`
  344. text-align: right;
  345. width: 100%;
  346. ${p => (p.color ? `color: ${p.color};` : '')}
  347. `;
  348. const AlignCenter = styled('div')`
  349. text-align: center;
  350. width: 100%;
  351. `;
  352. const StyledProjectAvatar = styled(ProjectAvatar)`
  353. top: ${space(0.25)};
  354. position: relative;
  355. padding-right: ${space(1)};
  356. `;
  357. // Not pretty but we need to override gridEditable styles since the original
  358. // styles have too much padding for small spaces
  359. const GridContainer = styled('div')`
  360. margin-bottom: ${space(1)};
  361. th {
  362. padding: 0 ${space(1)};
  363. }
  364. th:first-child {
  365. padding-left: ${space(2)};
  366. }
  367. th:last-child {
  368. padding-right: ${space(2)};
  369. }
  370. td {
  371. padding: ${space(1)};
  372. }
  373. td:first-child {
  374. padding-right: ${space(1)};
  375. padding-left: ${space(2)};
  376. }
  377. `;
  378. const NoValue = styled('span')`
  379. color: ${p => p.theme.gray300};
  380. `;
  381. const SearchBarContainer = styled('div')`
  382. display: flex;
  383. margin-top: ${space(2)};
  384. margin-bottom: ${space(1)};
  385. gap: ${space(1)};
  386. `;
  387. const StyledSearchBar = styled(SearchBar)`
  388. flex-grow: 1;
  389. `;
  390. const StyledPagination = styled(Pagination)`
  391. margin: 0;
  392. `;
  393. const Wrapper = styled('div')`
  394. display: flex;
  395. align-items: center;
  396. justify-content: flex-end;
  397. margin: 0;
  398. `;
  399. const TooltipHeader = styled('span')`
  400. ${p => p.theme.tooltipUnderline()};
  401. `;
  402. const StyledTooltip = styled(Tooltip)`
  403. top: 1px;
  404. position: relative;
  405. `;