Files
2024-02-15 17:01:50 +00:00

546 lines
17 KiB
TypeScript

import {useMemo, useState} from 'react';
import {Link} from 'react-router';
import styled from '@emotion/styled';
import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
import {Button, LinkButton} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import SearchBar from 'sentry/components/events/searchBar';
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 {SegmentedControl} from 'sentry/components/segmentedControl';
import {Tooltip} from 'sentry/components/tooltip';
import {IconChevron, IconPlay, IconProfiling} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import type {Sort} from 'sentry/utils/discover/fields';
import {generateEventSlug} from 'sentry/utils/discover/urls';
import {getShortEventId} from 'sentry/utils/events';
import {getDuration} from 'sentry/utils/formatters';
import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes';
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 useRouter from 'sentry/utils/useRouter';
import {useRoutes} from 'sentry/utils/useRoutes';
import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge';
import {useTransactionSamplesWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/queries/useTransactionSamplesWebVitalsQuery';
import type {
InteractionSpanSampleRowWithScore,
TransactionSampleRowWithScore,
} from 'sentry/views/performance/browser/webVitals/utils/types';
import {
DEFAULT_INDEXED_SORT,
SORTABLE_INDEXED_FIELDS,
SORTABLE_INDEXED_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';
import {generateReplayLink} from 'sentry/views/performance/transactionSummary/utils';
type Column = GridColumnHeader<keyof TransactionSampleRowWithScore>;
type InteractionsColumn = GridColumnHeader<keyof InteractionSpanSampleRowWithScore>;
const PAGELOADS_COLUMN_ORDER: GridColumnOrder<keyof TransactionSampleRowWithScore>[] = [
{key: 'id', width: COL_WIDTH_UNDEFINED, name: 'Event ID'},
{key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
{key: 'measurements.lcp', width: COL_WIDTH_UNDEFINED, name: 'LCP'},
{key: 'measurements.fcp', width: COL_WIDTH_UNDEFINED, name: 'FCP'},
{key: 'measurements.fid', width: COL_WIDTH_UNDEFINED, name: 'FID'},
{key: 'measurements.cls', width: COL_WIDTH_UNDEFINED, name: 'CLS'},
{key: 'measurements.ttfb', width: COL_WIDTH_UNDEFINED, name: 'TTFB'},
{key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
{key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
{key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'},
];
const INTERACTION_SAMPLES_COLUMN_ORDER: GridColumnOrder<
keyof InteractionSpanSampleRowWithScore
>[] = [
{key: 'user.display', width: COL_WIDTH_UNDEFINED, name: 'User'},
{key: 'measurements.inp', width: COL_WIDTH_UNDEFINED, name: 'INP'},
{key: 'profile.id', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
{key: 'replayId', width: COL_WIDTH_UNDEFINED, name: 'Replay'},
{key: 'totalScore', width: COL_WIDTH_UNDEFINED, name: 'Score'},
];
const INP_SEARCH_FILTER = 'has:measurements.fid (has:profile.id OR has:replayId)';
enum Dataset {
PAGELOADS = 'pageloads',
INTERACTIONS = 'interactions',
}
type Props = {
transaction: string;
limit?: number;
search?: string;
};
export function PageSamplePerformanceTable({transaction, search, limit = 9}: Props) {
const location = useLocation();
const {projects} = useProjects();
const organization = useOrganization();
const routes = useRoutes();
const router = useRouter();
const shouldUseStoredScores = useStoredScoresSetting();
const shouldReplaceFidWithInp = useReplaceFidWithInpSetting();
const [dataset, setDataset] = useState(Dataset.PAGELOADS);
const samplesColumnOrder = useMemo(() => {
if (shouldReplaceFidWithInp) {
return PAGELOADS_COLUMN_ORDER.filter(col => col.key !== 'measurements.fid');
}
return PAGELOADS_COLUMN_ORDER;
}, [shouldReplaceFidWithInp]);
const sortableFields = shouldUseStoredScores
? SORTABLE_INDEXED_FIELDS
: SORTABLE_INDEXED_FIELDS.filter(
field => !SORTABLE_INDEXED_SCORE_FIELDS.includes(field)
);
let sort = useWebVitalsSort({
defaultSort: DEFAULT_INDEXED_SORT,
sortableFields: sortableFields as unknown as string[],
});
// Need to map fid back to inp for rendering
if (shouldReplaceFidWithInp && sort.field === 'measurements.fid') {
sort = {...sort, field: 'measurements.inp'};
}
const replayLinkGenerator = generateReplayLink(routes);
const project = useMemo(
() => projects.find(p => p.id === String(location.query.project)),
[projects, location.query.project]
);
const query = decodeScalar(location.query.query);
const {
data: tableData,
isLoading,
pageLinks,
} = useTransactionSamplesWebVitalsQuery({
limit,
transaction,
query: search,
withProfiles: true,
enabled: dataset === Dataset.PAGELOADS,
});
const {
data: interactionsTableData,
isLoading: isInteractionsLoading,
pageLinks: interactionsPageLinks,
} = useTransactionSamplesWebVitalsQuery({
limit,
transaction,
query: `${INP_SEARCH_FILTER} ${search ?? ''}`,
withProfiles: true,
enabled: dataset === Dataset.INTERACTIONS,
});
const getFormattedDuration = (value: number) => {
return getDuration(value, value < 1 ? 0 : 2, true);
};
function renderHeadCell(col: Column | InteractionsColumn) {
function generateSortLink() {
const key = col.key === 'totalScore' ? '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 canSort = (sortableFields as ReadonlyArray<string>).includes(col.key);
if (
[
'measurements.fcp',
'measurements.lcp',
'measurements.ttfb',
'measurements.fid',
'measurements.cls',
'measurements.inp',
'transaction.duration',
].includes(col.key)
) {
if (canSort) {
return (
<SortLink
align="right"
title={col.name}
direction={sort?.field === col.key ? sort.kind : undefined}
canSort={canSort}
generateSortLink={generateSortLink}
/>
);
}
return (
<AlignRight>
<span>{col.name}</span>
</AlignRight>
);
}
if (col.key === 'totalScore') {
return (
<SortLink
title={
<AlignCenter>
<StyledTooltip
isHoverable
title={
<span>
{t('The overall performance rating of this page.')}
<br />
<ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
{t('How is this calculated?')}
</ExternalLink>
</span>
}
>
<TooltipHeader>{t('Perf Score')}</TooltipHeader>
</StyledTooltip>
</AlignCenter>
}
direction={sort?.field === col.key ? sort.kind : undefined}
canSort={canSort}
generateSortLink={generateSortLink}
align={undefined}
/>
);
}
if (col.key === 'replayId' || col.key === 'profile.id') {
return (
<AlignCenter>
<span>{col.name}</span>
</AlignCenter>
);
}
return <span>{col.name}</span>;
}
function renderBodyCell(
col: Column | InteractionsColumn,
row: TransactionSampleRowWithScore | InteractionSpanSampleRowWithScore
) {
const {key} = col;
if (key === 'totalScore') {
return (
<AlignCenter>
<PerformanceBadge score={row.totalScore} />
</AlignCenter>
);
}
if (key === 'transaction' && 'transaction' in row) {
return (
<NoOverflow>
{project && (
<StyledProjectAvatar
project={project}
direction="left"
size={16}
hasTooltip
tooltip={project.slug}
/>
)}
<Link
to={{...location, query: {...location.query, transaction: row.transaction}}}
>
{row.transaction}
</Link>
</NoOverflow>
);
}
if (
[
'measurements.fcp',
'measurements.lcp',
'measurements.ttfb',
'measurements.fid',
'measurements.inp',
'transaction.duration',
].includes(key)
) {
return (
<AlignRight>
{row[key] === undefined ? (
<NoValue>{' \u2014 '}</NoValue>
) : (
getFormattedDuration((row[key] as number) / 1000)
)}
</AlignRight>
);
}
if (['measurements.cls', 'opportunity'].includes(key)) {
return (
<AlignRight>
{row[key] === undefined ? (
<NoValue>{' \u2014 '}</NoValue>
) : (
Math.round((row[key] as number) * 100) / 100
)}
</AlignRight>
);
}
if (key === 'profile.id') {
const profileTarget =
defined(row.projectSlug) && defined(row['profile.id'])
? generateProfileFlamechartRoute({
orgSlug: organization.slug,
projectSlug: row.projectSlug,
profileId: String(row['profile.id']),
})
: null;
return (
<NoOverflow>
<AlignCenter>
{profileTarget && (
<Tooltip title={t('View Profile')}>
<LinkButton to={profileTarget} size="xs">
<IconProfiling size="xs" />
</LinkButton>
</Tooltip>
)}
</AlignCenter>
</NoOverflow>
);
}
if (key === 'replayId' && 'id' in row) {
const replayTarget =
row['transaction.duration'] !== undefined &&
replayLinkGenerator(
organization,
{
replayId: row.replayId,
id: row.id,
'transaction.duration': row['transaction.duration'],
timestamp: row.timestamp,
},
undefined
);
return (
<NoOverflow>
<AlignCenter>
{replayTarget && Object.keys(replayTarget).length > 0 && (
<Tooltip title={t('View Replay')}>
<LinkButton to={replayTarget} size="xs">
<IconPlay size="xs" />
</LinkButton>
</Tooltip>
)}
</AlignCenter>
</NoOverflow>
);
}
if (key === 'id' && 'id' in row) {
const eventSlug = generateEventSlug({...row, project: row.projectSlug});
const eventTarget = getTransactionDetailsUrl(organization.slug, eventSlug);
return (
<NoOverflow>
<Tooltip title={t('View Transaction')}>
<Link to={eventTarget}>{getShortEventId(row.id)}</Link>
</Tooltip>
</NoOverflow>
);
}
return <NoOverflow>{row[key]}</NoOverflow>;
}
return (
<span>
<SearchBarContainer>
{shouldReplaceFidWithInp && (
<SegmentedControl
size="md"
value={dataset}
onChange={newDataSet => {
// Reset pagination and sort when switching datasets
router.replace({
...location,
query: {...location.query, sort: undefined, cursor: undefined},
});
setDataset(newDataSet);
}}
>
<SegmentedControl.Item key={Dataset.PAGELOADS}>
{t('Pageloads')}
</SegmentedControl.Item>
<SegmentedControl.Item key={Dataset.INTERACTIONS}>
{t('Interactions')}
</SegmentedControl.Item>
</SegmentedControl>
)}
<StyledSearchBar
query={query}
organization={organization}
onSearch={queryString =>
router.replace({
...location,
query: {...location.query, query: queryString},
})
}
/>
<StyledPagination
pageLinks={dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks}
disabled={dataset === Dataset.INTERACTIONS ? isInteractionsLoading : isLoading}
size="md"
/>
{/* 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 */}
{!(dataset === Dataset.INTERACTIONS ? interactionsPageLinks : pageLinks) && (
<Wrapper>
<ButtonBar merged>
<Button
icon={<IconChevron direction="left" />}
disabled
aria-label={t('Previous')}
/>
<Button
icon={<IconChevron direction="right" />}
disabled
aria-label={t('Next')}
/>
</ButtonBar>
</Wrapper>
)}
</SearchBarContainer>
<GridContainer>
{dataset === Dataset.PAGELOADS && (
<GridEditable
isLoading={isLoading}
columnOrder={samplesColumnOrder}
columnSortBy={[]}
data={tableData}
grid={{
renderHeadCell,
renderBodyCell,
}}
location={location}
minimumColWidth={70}
/>
)}
{dataset === Dataset.INTERACTIONS && (
<GridEditable
isLoading={isInteractionsLoading}
columnOrder={INTERACTION_SAMPLES_COLUMN_ORDER}
columnSortBy={[]}
data={interactionsTableData as unknown as InteractionSpanSampleRowWithScore[]}
grid={{
renderHeadCell,
renderBodyCell,
}}
location={location}
minimumColWidth={70}
/>
)}
</GridContainer>
</span>
);
}
const NoOverflow = styled('span')`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const AlignRight = styled('span')<{color?: string}>`
text-align: right;
width: 100%;
${p => (p.color ? `color: ${p.color};` : '')}
`;
const AlignCenter = styled('div')`
display: block;
margin: auto;
text-align: center;
width: 100%;
`;
const StyledProjectAvatar = styled(ProjectAvatar)`
top: ${space(0.25)};
position: relative;
padding-right: ${space(1)};
`;
// Not pretty but we need to override gridEditable styles since the original
// styles have too much padding for small spaces
const GridContainer = styled('div')`
margin-bottom: ${space(1)};
th {
padding: 0 ${space(1)};
}
th:first-child {
padding-left: ${space(2)};
}
th:last-child {
padding-right: ${space(2)};
}
td {
padding: ${space(1)};
}
td:first-child {
padding-right: ${space(1)};
padding-left: ${space(2)};
}
`;
const NoValue = styled('span')`
color: ${p => p.theme.gray300};
`;
const SearchBarContainer = styled('div')`
display: flex;
margin-top: ${space(2)};
margin-bottom: ${space(1)};
gap: ${space(1)};
`;
const StyledSearchBar = styled(SearchBar)`
flex-grow: 1;
`;
const StyledPagination = styled(Pagination)`
margin: 0;
`;
const Wrapper = styled('div')`
display: flex;
align-items: center;
justify-content: flex-end;
margin: 0;
`;
const TooltipHeader = styled('span')`
${p => p.theme.tooltipUnderline()};
`;
const StyledTooltip = styled(Tooltip)`
top: 1px;
position: relative;
`;