import {useMemo} from 'react'; import {browserHistory, Link} from 'react-router'; import styled from '@emotion/styled'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import type {GridColumnHeader, GridColumnOrder} from 'sentry/components/gridEditable'; import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable'; import SortLink from 'sentry/components/gridEditable/sortLink'; import ExternalLink from 'sentry/components/links/externalLink'; import Pagination from 'sentry/components/pagination'; import SearchBar from 'sentry/components/searchBar'; import {Tooltip} from 'sentry/components/tooltip'; import {IconChevron} from 'sentry/icons/iconChevron'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Sort} from 'sentry/utils/discover/fields'; import {parseFunction} from 'sentry/utils/discover/fields'; import {formatAbbreviatedNumber, getDuration} from 'sentry/utils/formatters'; import {decodeScalar} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge'; import {calculateOpportunity} from 'sentry/views/performance/browser/webVitals/utils/calculateOpportunity'; import {calculatePerformanceScoreFromTableDataRow} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/calculatePerformanceScore'; import {useProjectRawWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery'; import {calculatePerformanceScoreFromStoredTableDataRow} from 'sentry/views/performance/browser/webVitals/utils/queries/storedScoreQueries/calculatePerformanceScoreFromStored'; import {useProjectWebVitalsScoresQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/storedScoreQueries/useProjectWebVitalsScoresQuery'; import {useTransactionWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionWebVitalsQuery'; import type {RowWithScoreAndOpportunity} from 'sentry/views/performance/browser/webVitals/utils/types'; import { SORTABLE_FIELDS, SORTABLE_SCORE_FIELDS, } from 'sentry/views/performance/browser/webVitals/utils/types'; import {useReplaceFidWithInpSetting} from 'sentry/views/performance/browser/webVitals/utils/useReplaceFidWithInpSetting'; import {useStoredScoresSetting} from 'sentry/views/performance/browser/webVitals/utils/useStoredScoresSetting'; import {useWebVitalsSort} from 'sentry/views/performance/browser/webVitals/utils/useWebVitalsSort'; type Column = GridColumnHeader; const INP_COLUMN: GridColumnOrder = { key: 'p75(measurements.inp)', width: COL_WIDTH_UNDEFINED, name: 'INP', }; const COLUMN_ORDER: GridColumnOrder[] = [ {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'}, {key: 'count()', width: COL_WIDTH_UNDEFINED, name: 'Pageloads'}, {key: 'p75(measurements.lcp)', width: COL_WIDTH_UNDEFINED, name: 'LCP'}, {key: 'p75(measurements.fcp)', width: COL_WIDTH_UNDEFINED, name: 'FCP'}, {key: 'p75(measurements.fid)', width: COL_WIDTH_UNDEFINED, name: 'FID'}, {key: 'p75(measurements.cls)', width: COL_WIDTH_UNDEFINED, name: 'CLS'}, {key: 'p75(measurements.ttfb)', width: COL_WIDTH_UNDEFINED, name: 'TTFB'}, {key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'}, {key: 'opportunity', width: COL_WIDTH_UNDEFINED, name: 'Opportunity'}, ]; const MAX_ROWS = 25; const DEFAULT_SORT: Sort = { field: 'opportunity_score(measurements.score.total)', kind: 'desc', }; export function PagePerformanceTable() { const organization = useOrganization(); const location = useLocation(); const {projects} = useProjects(); const shouldUseStoredScores = useStoredScoresSetting(); const shouldReplaceFidWithInp = useReplaceFidWithInpSetting(); const columnOrder = useMemo(() => { const columns = [...COLUMN_ORDER]; if (shouldReplaceFidWithInp) { columns.splice(4, 1, INP_COLUMN); } return columns; }, [shouldReplaceFidWithInp]); const query = decodeScalar(location.query.query, ''); const project = useMemo( () => projects.find(p => p.id === String(location.query.project)), [projects, location.query.project] ); let sort = useWebVitalsSort({defaultSort: DEFAULT_SORT}); // Need to map fid back to inp for rendering if (shouldReplaceFidWithInp && sort.field === 'p75(measurements.fid)') { sort = {...sort, field: 'p75(measurements.inp)'}; } const {data: projectData, isLoading: isProjectWebVitalsQueryLoading} = useProjectRawWebVitalsQuery({transaction: query}); const {data: projectScoresData, isLoading: isProjectScoresLoading} = useProjectWebVitalsScoresQuery({ transaction: query, enabled: shouldUseStoredScores, }); const projectScore = shouldUseStoredScores ? calculatePerformanceScoreFromStoredTableDataRow(projectScoresData?.data?.[0]) : calculatePerformanceScoreFromTableDataRow(projectData?.data?.[0]); const { data, pageLinks, isLoading: isTransactionWebVitalsQueryLoading, } = useTransactionWebVitalsQuery({ limit: MAX_ROWS, transaction: query, defaultSort: DEFAULT_SORT, }); const count = projectData?.data?.[0]?.['count()'] as number; const scoreCount = projectScoresData?.data?.[0]?.[ 'count_scores(measurements.score.total)' ] as number; const tableData: RowWithScoreAndOpportunity[] = data.map(row => ({ ...row, opportunity: shouldUseStoredScores ? (((row as RowWithScoreAndOpportunity).opportunity ?? 0) * 100) / scoreCount : calculateOpportunity( projectScore.totalScore ?? 0, count, row.totalScore, row['count()'] ), })); const getFormattedDuration = (value: number) => { return getDuration(value, value < 1 ? 0 : 2, true); }; function renderHeadCell(col: Column) { function generateSortLink() { const key = col.key === 'totalScore' ? 'avg(measurements.score.total)' : col.key === 'opportunity' ? 'opportunity_score(measurements.score.total)' : col.key; let newSortDirection: Sort['kind'] = 'desc'; if (sort?.field === key) { if (sort.kind === 'desc') { newSortDirection = 'asc'; } } const newSort = `${newSortDirection === 'desc' ? '-' : ''}${key}`; return { ...location, query: {...location.query, sort: newSort}, }; } const sortableFields = shouldUseStoredScores ? SORTABLE_FIELDS : SORTABLE_FIELDS.filter(field => !SORTABLE_SCORE_FIELDS.includes(field)); const canSort = (sortableFields as unknown as string[]).includes(col.key); if (canSort && !['totalScore', 'opportunity'].includes(col.key)) { return ( ); } if (col.key === 'totalScore') { return ( {t('The overall performance rating of this page.')}
{t('How is this calculated?')} } > {t('Perf Score')}} direction={sort?.field === col.key ? sort.kind : undefined} canSort={canSort} generateSortLink={generateSortLink} align={undefined} />
); } if (col.key === 'opportunity') { return ( {t( "A number rating how impactful a performance improvement on this page would be to your application's overall Performance Score." )}
{t('How is this calculated?')} } > {col.name}} direction={sort?.field === col.key ? sort.kind : undefined} canSort={canSort} generateSortLink={generateSortLink} />
); } return {col.name}; } function renderBodyCell(col: Column, row: RowWithScoreAndOpportunity) { const {key} = col; if (key === 'totalScore') { return ( ); } if (key === 'count()') { return {formatAbbreviatedNumber(row['count()'])}; } if (key === 'transaction') { return ( {project && ( )} {row.transaction} ); } if ( [ 'p75(measurements.fcp)', 'p75(measurements.lcp)', 'p75(measurements.ttfb)', 'p75(measurements.fid)', 'p75(measurements.inp)', ].includes(key) ) { const measurement = parseFunction(key)?.arguments?.[0]; const func = shouldUseStoredScores ? 'count_scores' : 'count_web_vitals'; const args = [ shouldUseStoredScores ? measurement?.replace('measurements.', 'measurements.score.') : measurement, ...(shouldUseStoredScores ? [] : ['any']), ]; const countWebVitalKey = `${func}(${args.join(', ')})`; const countWebVital = row[countWebVitalKey]; if (measurement === undefined || countWebVital === 0) { return ( {' \u2014 '} ); } return {getFormattedDuration((row[key] as number) / 1000)}; } if (key === 'p75(measurements.cls)') { const countWebVitalKey = shouldUseStoredScores ? 'count_scores(measurements.score.cls)' : 'count_web_vitals(measurements.cls, any)'; const countWebVital = row[countWebVitalKey]; if (countWebVital === 0) { return ( {' \u2014 '} ); } return {Math.round((row[key] as number) * 100) / 100}; } if (key === 'opportunity') { if (row.opportunity !== undefined) { return ( {Math.round((row.opportunity as number) * 100) / 100} ); } return null; } return {row[key]}; } const handleSearch = (newQuery: string) => { browserHistory.push({ ...location, query: { ...location.query, query: newQuery === '' ? undefined : `*${newQuery}*`, cursor: undefined, }, }); }; return ( {/* The Pagination component disappears if pageLinks is not defined, which happens any time the table data is loading. So we render a disabled button bar if pageLinks is not defined to minimize ui shifting */} {!pageLinks && (