pagePerformanceTable.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import {useMemo} from 'react';
  2. import {browserHistory, Link} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
  5. import {Button} from 'sentry/components/button';
  6. import ButtonBar from 'sentry/components/buttonBar';
  7. import GridEditable, {
  8. COL_WIDTH_UNDEFINED,
  9. GridColumnHeader,
  10. GridColumnOrder,
  11. } from 'sentry/components/gridEditable';
  12. import SortLink from 'sentry/components/gridEditable/sortLink';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import Pagination from 'sentry/components/pagination';
  15. import SearchBar from 'sentry/components/searchBar';
  16. import {Tooltip} from 'sentry/components/tooltip';
  17. import {IconChevron} from 'sentry/icons/iconChevron';
  18. import {t} from 'sentry/locale';
  19. import {space} from 'sentry/styles/space';
  20. import {Sort} from 'sentry/utils/discover/fields';
  21. import {formatAbbreviatedNumber, getDuration} from 'sentry/utils/formatters';
  22. import {decodeScalar} from 'sentry/utils/queryString';
  23. import {useLocation} from 'sentry/utils/useLocation';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. import useProjects from 'sentry/utils/useProjects';
  26. import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
  27. import {calculateOpportunity} from 'sentry/views/performance/browser/webVitals/utils/calculateOpportunity';
  28. import {calculatePerformanceScoreFromTableDataRow} from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore';
  29. import {
  30. Row,
  31. SORTABLE_FIELDS,
  32. } from 'sentry/views/performance/browser/webVitals/utils/types';
  33. import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery';
  34. import {useTransactionWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useTransactionWebVitalsQuery';
  35. import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort';
  36. type RowWithScoreAndOpportunity = Row & {score: number; opportunity?: number};
  37. type Column = GridColumnHeader<keyof RowWithScoreAndOpportunity>;
  38. const columnOrder: GridColumnOrder<keyof RowWithScoreAndOpportunity>[] = [
  39. {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'},
  40. {key: 'count()', width: COL_WIDTH_UNDEFINED, name: 'Pageloads'},
  41. {key: 'p75(measurements.lcp)', width: COL_WIDTH_UNDEFINED, name: 'LCP'},
  42. {key: 'p75(measurements.fcp)', width: COL_WIDTH_UNDEFINED, name: 'FCP'},
  43. {key: 'p75(measurements.fid)', width: COL_WIDTH_UNDEFINED, name: 'FID'},
  44. {key: 'p75(measurements.cls)', width: COL_WIDTH_UNDEFINED, name: 'CLS'},
  45. {key: 'p75(measurements.ttfb)', width: COL_WIDTH_UNDEFINED, name: 'TTFB'},
  46. {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'},
  47. {key: 'opportunity', width: COL_WIDTH_UNDEFINED, name: 'Opportunity'},
  48. ];
  49. const MAX_ROWS = 25;
  50. export function PagePerformanceTable() {
  51. const organization = useOrganization();
  52. const location = useLocation();
  53. const {projects} = useProjects();
  54. const query = decodeScalar(location.query.query, '');
  55. const project = useMemo(
  56. () => projects.find(p => p.id === String(location.query.project)),
  57. [projects, location.query.project]
  58. );
  59. const sort = useWebVitalsSort();
  60. const {data: projectData, isLoading: isProjectWebVitalsQueryLoading} =
  61. useProjectWebVitalsQuery({transaction: query});
  62. const projectScore = calculatePerformanceScoreFromTableDataRow(projectData?.data?.[0]);
  63. const {
  64. data,
  65. pageLinks,
  66. isLoading: isTransactionWebVitalsQueryLoading,
  67. } = useTransactionWebVitalsQuery({limit: MAX_ROWS, transaction: query});
  68. const count = projectData?.data?.[0]?.['count()'] as number;
  69. const tableData: RowWithScoreAndOpportunity[] = data.map(row => ({
  70. ...row,
  71. opportunity:
  72. count !== undefined
  73. ? calculateOpportunity(
  74. projectScore.totalScore ?? 0,
  75. count,
  76. row.score,
  77. row['count()']
  78. )
  79. : undefined,
  80. }));
  81. const getFormattedDuration = (value: number) => {
  82. return getDuration(value, value < 1 ? 0 : 2, true);
  83. };
  84. function renderHeadCell(col: Column) {
  85. function generateSortLink() {
  86. let newSortDirection: Sort['kind'] = 'desc';
  87. if (sort?.field === col.key) {
  88. if (sort.kind === 'desc') {
  89. newSortDirection = 'asc';
  90. }
  91. }
  92. const newSort = `${newSortDirection === 'desc' ? '-' : ''}${col.key}`;
  93. return {
  94. ...location,
  95. query: {...location.query, sort: newSort},
  96. };
  97. }
  98. const canSort = (SORTABLE_FIELDS as unknown as string[]).includes(col.key);
  99. if (canSort) {
  100. return (
  101. <SortLink
  102. align="right"
  103. title={col.name}
  104. direction={sort?.field === col.key ? sort.kind : undefined}
  105. canSort={canSort}
  106. generateSortLink={generateSortLink}
  107. />
  108. );
  109. }
  110. if (col.key === 'score') {
  111. return (
  112. <AlignCenter>
  113. <StyledTooltip
  114. isHoverable
  115. title={
  116. <span>
  117. {t('The overall performance rating of this page.')}
  118. <br />
  119. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
  120. {t('How is this calculated?')}
  121. </ExternalLink>
  122. </span>
  123. }
  124. >
  125. <TooltipHeader>{t('Perf Score')}</TooltipHeader>
  126. </StyledTooltip>
  127. </AlignCenter>
  128. );
  129. }
  130. if (col.key === 'opportunity') {
  131. return (
  132. <AlignRight>
  133. <StyledTooltip
  134. isHoverable
  135. title={
  136. <span>
  137. {t(
  138. "A number rating how impactful a performance improvement on this page would be to your application's overall Performance Score."
  139. )}
  140. <br />
  141. <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#opportunity">
  142. {t('How is this calculated?')}
  143. </ExternalLink>
  144. </span>
  145. }
  146. >
  147. <TooltipHeader>{col.name}</TooltipHeader>
  148. </StyledTooltip>
  149. </AlignRight>
  150. );
  151. }
  152. return <span>{col.name}</span>;
  153. }
  154. function renderBodyCell(col: Column, row: RowWithScoreAndOpportunity) {
  155. const {key} = col;
  156. if (key === 'score') {
  157. return (
  158. <AlignCenter>
  159. <PerformanceBadge score={row.score} />
  160. </AlignCenter>
  161. );
  162. }
  163. if (key === 'count()') {
  164. return <AlignRight>{formatAbbreviatedNumber(row['count()'])}</AlignRight>;
  165. }
  166. if (key === 'transaction') {
  167. return (
  168. <NoOverflow>
  169. {project && (
  170. <StyledProjectAvatar
  171. project={project}
  172. direction="left"
  173. size={16}
  174. hasTooltip
  175. tooltip={project.slug}
  176. />
  177. )}
  178. <Link
  179. to={{
  180. ...location,
  181. ...(organization.features.includes(
  182. 'starfish-browser-webvitals-pageoverview-v2'
  183. )
  184. ? {pathname: `${location.pathname}overview/`}
  185. : {}),
  186. query: {...location.query, transaction: row.transaction, query: undefined},
  187. }}
  188. >
  189. {row.transaction}
  190. </Link>
  191. </NoOverflow>
  192. );
  193. }
  194. if (
  195. [
  196. 'p75(measurements.fcp)',
  197. 'p75(measurements.lcp)',
  198. 'p75(measurements.ttfb)',
  199. 'p75(measurements.fid)',
  200. ].includes(key)
  201. ) {
  202. return <AlignRight>{getFormattedDuration((row[key] as number) / 1000)}</AlignRight>;
  203. }
  204. if (key === 'p75(measurements.cls)') {
  205. return <AlignRight>{Math.round((row[key] as number) * 100) / 100}</AlignRight>;
  206. }
  207. if (key === 'opportunity') {
  208. if (row.opportunity !== undefined) {
  209. return (
  210. <AlignRight>{Math.round((row.opportunity as number) * 100) / 100}</AlignRight>
  211. );
  212. }
  213. return null;
  214. }
  215. return <NoOverflow>{row[key]}</NoOverflow>;
  216. }
  217. const handleSearch = (newQuery: string) => {
  218. browserHistory.push({
  219. ...location,
  220. query: {
  221. ...location.query,
  222. query: newQuery === '' ? undefined : `*${newQuery}*`,
  223. cursor: undefined,
  224. },
  225. });
  226. };
  227. return (
  228. <span>
  229. <SearchBarContainer>
  230. <StyledSearchBar
  231. placeholder={t('Search for more Pages')}
  232. onSearch={handleSearch}
  233. />
  234. <StyledPagination
  235. pageLinks={pageLinks}
  236. disabled={isProjectWebVitalsQueryLoading || isTransactionWebVitalsQueryLoading}
  237. size="md"
  238. />
  239. {/* The Pagination component disappears if pageLinks is not defined,
  240. which happens any time the table data is loading. So we render a
  241. disabled button bar if pageLinks is not defined to minimize ui shifting */}
  242. {!pageLinks && (
  243. <Wrapper>
  244. <ButtonBar merged>
  245. <Button
  246. icon={<IconChevron direction="left" size="sm" />}
  247. size="md"
  248. disabled
  249. aria-label={t('Previous')}
  250. />
  251. <Button
  252. icon={<IconChevron direction="right" size="sm" />}
  253. size="md"
  254. disabled
  255. aria-label={t('Next')}
  256. />
  257. </ButtonBar>
  258. </Wrapper>
  259. )}
  260. </SearchBarContainer>
  261. <GridContainer>
  262. <GridEditable
  263. isLoading={isProjectWebVitalsQueryLoading || isTransactionWebVitalsQueryLoading}
  264. columnOrder={columnOrder}
  265. columnSortBy={[]}
  266. data={tableData}
  267. grid={{
  268. renderHeadCell,
  269. renderBodyCell,
  270. }}
  271. location={location}
  272. />
  273. </GridContainer>
  274. </span>
  275. );
  276. }
  277. const NoOverflow = styled('span')`
  278. overflow: hidden;
  279. text-overflow: ellipsis;
  280. white-space: nowrap;
  281. `;
  282. const AlignRight = styled('span')<{color?: string}>`
  283. text-align: right;
  284. width: 100%;
  285. ${p => (p.color ? `color: ${p.color};` : '')}
  286. `;
  287. const AlignCenter = styled('span')`
  288. text-align: center;
  289. width: 100%;
  290. `;
  291. const StyledProjectAvatar = styled(ProjectAvatar)`
  292. top: ${space(0.25)};
  293. position: relative;
  294. padding-right: ${space(1)};
  295. `;
  296. const SearchBarContainer = styled('div')`
  297. display: flex;
  298. margin-bottom: ${space(1)};
  299. gap: ${space(1)};
  300. `;
  301. const GridContainer = styled('div')`
  302. margin-bottom: ${space(1)};
  303. `;
  304. const TooltipHeader = styled('span')`
  305. ${p => p.theme.tooltipUnderline()};
  306. `;
  307. const StyledSearchBar = styled(SearchBar)`
  308. flex-grow: 1;
  309. `;
  310. const StyledPagination = styled(Pagination)`
  311. margin: 0;
  312. `;
  313. const Wrapper = styled('div')`
  314. display: flex;
  315. align-items: center;
  316. justify-content: flex-end;
  317. margin: 0;
  318. `;
  319. const StyledTooltip = styled(Tooltip)`
  320. top: 1px;
  321. position: relative;
  322. `;