import {useMemo} from 'react'; import {Link} from 'react-router'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import ChartZoom from 'sentry/components/charts/chartZoom'; import MarkArea from 'sentry/components/charts/components/markArea'; import MarkLine from 'sentry/components/charts/components/markLine'; import {LineChart, LineChartSeries} from 'sentry/components/charts/lineChart'; import GridEditable, { COL_WIDTH_UNDEFINED, GridColumnHeader, GridColumnOrder, GridColumnSortBy, } from 'sentry/components/gridEditable'; import {Tooltip} from 'sentry/components/tooltip'; import {t} from 'sentry/locale'; import {getDuration} from 'sentry/utils/formatters'; import { PageErrorAlert, PageErrorProvider, } from 'sentry/utils/performance/contexts/pageError'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useRouter from 'sentry/utils/useRouter'; import {PerformanceBadge} from 'sentry/views/performance/browser/webVitals/components/performanceBadge'; import {WebVitalDescription} from 'sentry/views/performance/browser/webVitals/components/webVitalDescription'; import {calculateOpportunity} from 'sentry/views/performance/browser/webVitals/utils/calculateOpportunity'; import { calculatePerformanceScore, PERFORMANCE_SCORE_MEDIANS, PERFORMANCE_SCORE_P90S, } from 'sentry/views/performance/browser/webVitals/utils/calculatePerformanceScore'; import { Row, RowWithScore, WebVitals, } from 'sentry/views/performance/browser/webVitals/utils/types'; import {useProjectWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsQuery'; import {useProjectWebVitalsValuesTimeseriesQuery} from 'sentry/views/performance/browser/webVitals/utils/useProjectWebVitalsValuesTimeseriesQuery'; import {useTransactionWebVitalsQuery} from 'sentry/views/performance/browser/webVitals/utils/useTransactionWebVitalsQuery'; import DetailPanel from 'sentry/views/starfish/components/detailPanel'; type Column = GridColumnHeader; const columnOrder: GridColumnOrder[] = [ {key: 'transaction', width: COL_WIDTH_UNDEFINED, name: 'Pages'}, {key: 'count()', width: COL_WIDTH_UNDEFINED, name: 'Pageloads'}, {key: 'webVital', width: COL_WIDTH_UNDEFINED, name: 'Web Vital'}, {key: 'score', width: COL_WIDTH_UNDEFINED, name: 'Score'}, {key: 'opportunity', width: COL_WIDTH_UNDEFINED, name: 'Opportunity'}, ]; const sort: GridColumnSortBy = {key: 'count()', order: 'desc'}; const MAX_ROWS = 10; export function WebVitalsDetailPanel({ webVital, onClose, }: { onClose: () => void; webVital: WebVitals | null; }) { const organization = useOrganization(); const location = useLocation(); const theme = useTheme(); const pageFilters = usePageFilters(); const router = useRouter(); const {period, start, end, utc} = pageFilters.selection.datetime; const transaction = location.query.transaction ? Array.isArray(location.query.transaction) ? location.query.transaction[0] : location.query.transaction : undefined; const {data: projectData} = useProjectWebVitalsQuery({transaction}); const projectScore = calculatePerformanceScore({ lcp: projectData?.data[0]['p75(measurements.lcp)'] as number, fcp: projectData?.data[0]['p75(measurements.fcp)'] as number, cls: projectData?.data[0]['p75(measurements.cls)'] as number, ttfb: projectData?.data[0]['p75(measurements.ttfb)'] as number, fid: projectData?.data[0]['p75(measurements.fid)'] as number, }); const {data, isLoading} = useTransactionWebVitalsQuery({ transaction, orderBy: webVital, limit: 100, }); const dataByOpportunity = useMemo(() => { if (!data) { return []; } const count = projectData?.data[0]['count()'] as number; return data .map(row => ({ ...row, opportunity: calculateOpportunity( projectScore[`${webVital}Score`], count, row[`${webVital}Score`], row['count()'] ), })) .sort((a, b) => b.opportunity - a.opportunity) .slice(0, MAX_ROWS); }, [data, projectData?.data, projectScore, webVital]); const {data: timeseriesData, isLoading: isTimeseriesLoading} = useProjectWebVitalsValuesTimeseriesQuery({transaction}); const webVitalData: LineChartSeries[] = [ { data: !isTimeseriesLoading && webVital ? timeseriesData?.[webVital].map(({name, value}) => ({ name, value, })) : [], seriesName: webVital ?? '', }, ]; const showPoorMarkLine = webVitalData[0].data?.some( ({value}) => value > PERFORMANCE_SCORE_MEDIANS[webVital ?? ''] ); const showMehMarkLine = webVitalData[0].data?.some( ({value}) => value >= PERFORMANCE_SCORE_P90S[webVital ?? ''] ); const showGoodMarkLine = webVitalData[0].data?.every( ({value}) => value < PERFORMANCE_SCORE_P90S[webVital ?? ''] ); const goodMarkArea = MarkArea({ silent: true, itemStyle: { color: theme.green300, opacity: 0.1, }, data: [ [ { yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''], }, { yAxis: 0, }, ], ], }); const mehMarkArea = MarkArea({ silent: true, itemStyle: { color: theme.yellow300, opacity: 0.1, }, data: [ [ { yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''], }, { yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''], }, ], ], }); const poorMarkArea = MarkArea({ silent: true, itemStyle: { color: theme.red300, opacity: 0.1, }, data: [ [ { yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''], }, { yAxis: Infinity, }, ], ], }); const goodMarkLine = MarkLine({ silent: true, lineStyle: { color: theme.green300, }, label: { formatter: () => 'Good', position: 'insideEndBottom', color: theme.green300, }, data: showGoodMarkLine ? [ [ {xAxis: 'min', y: 10}, {xAxis: 'max', y: 10}, ], ] : [ { yAxis: PERFORMANCE_SCORE_P90S[webVital ?? ''], }, ], }); const mehMarkLine = MarkLine({ silent: true, lineStyle: { color: theme.yellow300, }, label: { formatter: () => 'Meh', position: 'insideEndBottom', color: theme.yellow300, }, data: showMehMarkLine && !showPoorMarkLine ? [ [ {xAxis: 'min', y: 10}, {xAxis: 'max', y: 10}, ], ] : [ { yAxis: PERFORMANCE_SCORE_MEDIANS[webVital ?? ''], }, ], }); const poorMarkLine = MarkLine({ silent: true, lineStyle: { color: theme.red300, }, label: { formatter: () => 'Poor', position: 'insideEndBottom', color: theme.red300, }, data: [ [ {xAxis: 'min', y: 10}, {xAxis: 'max', y: 10}, ], ], }); webVitalData.push({ seriesName: '', type: 'line', markArea: goodMarkArea, data: [], }); webVitalData.push({ seriesName: '', type: 'line', markArea: mehMarkArea, data: [], }); webVitalData.push({ seriesName: '', type: 'line', markArea: poorMarkArea, data: [], }); webVitalData.push({ seriesName: '', type: 'line', markLine: goodMarkLine, data: [], }); webVitalData.push({ seriesName: '', type: 'line', markLine: mehMarkLine, data: [], }); if (showPoorMarkLine) { webVitalData.push({ seriesName: '', type: 'line', markLine: poorMarkLine, data: [], }); } const detailKey = webVital; const renderHeadCell = (col: Column) => { if (col.key === 'transaction') { return {col.name}; } if (col.key === 'webVital') { return {`${webVital} P75`}; } if (col.key === 'score') { return {`${webVital} ${col.name}`}; } if (col.key === 'opportunity') { return ( {col.name} ); } return {col.name}; }; const getFormattedDuration = (value: number) => { if (value < 1000) { return getDuration(value / 1000, 0, true); } return getDuration(value / 1000, 2, true); }; const renderBodyCell = (col: Column, row: RowWithScore) => { const {key} = col; if (key === 'score') { return ( ); } if (col.key === 'webVital') { let value: string | number = row[mapWebVitalToColumn(webVital)]; if (webVital && ['lcp', 'fcp', 'ttfb', 'fid'].includes(webVital)) { value = getFormattedDuration(value); } else if (webVital === 'cls') { value = value?.toFixed(2); } return {value}; } if (key === 'transaction') { return ( {row.transaction} ); } return {row[key]}; }; return ( {webVital && ( )} {webVital && ( {zoomRenderProps => ( )} )} {!transaction && ( )} ); } const mapWebVitalToColumn = (webVital?: WebVitals | null) => { switch (webVital) { case 'lcp': return 'p75(measurements.lcp)'; case 'fcp': return 'p75(measurements.fcp)'; case 'cls': return 'p75(measurements.cls)'; case 'ttfb': return 'p75(measurements.ttfb)'; case 'fid': return 'p75(measurements.fid)'; default: return 'count()'; } }; const NoOverflow = styled('span')` overflow: hidden; text-overflow: ellipsis; `; const AlignRight = styled('span')<{color?: string}>` text-align: right; width: 100%; ${p => (p.color ? `color: ${p.color};` : '')} `; const ChartContainer = styled('div')` position: relative; flex: 1; `; const AlignCenter = styled('span')` text-align: center; width: 100%; `; const OpportunityHeader = styled('span')` ${p => p.theme.tooltipUnderline()}; `;