import styled from '@emotion/styled'; 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 Link from 'sentry/components/links/link'; 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 {trackAnalytics} from 'sentry/utils/analytics'; import {browserHistory} from 'sentry/utils/browserHistory'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import type {Sort} from 'sentry/utils/discover/fields'; import {parseFunction} from 'sentry/utils/discover/fields'; import getDuration from 'sentry/utils/duration/getDuration'; import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {escapeFilterValue} from 'sentry/utils/tokenizeSearch'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import {PerformanceBadge} from 'sentry/views/insights/browser/webVitals/components/performanceBadge'; import {useTransactionWebVitalsScoresQuery} from 'sentry/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery'; import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings'; import type {RowWithScoreAndOpportunity} from 'sentry/views/insights/browser/webVitals/types'; import {SORTABLE_FIELDS} from 'sentry/views/insights/browser/webVitals/types'; import decodeBrowserTypes from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType'; import {useWebVitalsSort} from 'sentry/views/insights/browser/webVitals/utils/useWebVitalsSort'; import { ModuleName, SpanIndexedField, type SubregionCode, } from 'sentry/views/insights/types'; type Column = GridColumnHeader; const COLUMN_ORDER: GridColumnOrder[] = [ {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'}, {key: 'project', width: COL_WIDTH_UNDEFINED, name: 'Project'}, {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.inp)', width: COL_WIDTH_UNDEFINED, name: 'INP', }, {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 location = useLocation(); const organization = useOrganization(); const columnOrder = COLUMN_ORDER; const query = decodeScalar(location.query.query, ''); const browserTypes = decodeBrowserTypes(location.query[SpanIndexedField.BROWSER_NAME]); const subregions = decodeList( location.query[SpanIndexedField.USER_GEO_SUBREGION] ) as SubregionCode[]; const sort = useWebVitalsSort({defaultSort: DEFAULT_SORT}); const { data, meta, pageLinks, isPending: isTransactionWebVitalsQueryLoading, } = useTransactionWebVitalsScoresQuery({ limit: MAX_ROWS, transaction: query !== '' ? `*${escapeFilterValue(query)}*` : undefined, defaultSort: DEFAULT_SORT, shouldEscapeFilters: false, browserTypes, subregions, }); const tableData: RowWithScoreAndOpportunity[] = data.map(row => ({ ...row, opportunity: ((row as RowWithScoreAndOpportunity).opportunity ?? 0) * 100, })); 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 = SORTABLE_FIELDS; 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 ( {row.transaction} ); } if ( [ 'p75(measurements.fcp)', 'p75(measurements.lcp)', 'p75(measurements.ttfb)', 'p75(measurements.inp)', ].includes(key) ) { const measurement = parseFunction(key)?.arguments?.[0]; const func = 'count_scores'; const args = [measurement?.replace('measurements.', 'measurements.score.')]; 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 = 'count_scores(measurements.score.cls)'; 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; } if (!meta?.fields) { return {row[key]}; } const renderer = getFieldRenderer(col.key, meta.fields, false); return renderer(row, { location, organization, unit: meta.units?.[col.key], }); } const handleSearch = (newQuery: string) => { trackAnalytics('insight.general.search', { organization, query: newQuery, source: ModuleName.VITAL, }); 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 && (