import {Fragment} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import ExternalLink from 'sentry/components/links/externalLink'; import QuestionTooltip from 'sentry/components/questionTooltip'; import {Tooltip} from 'sentry/components/tooltip'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {TableData} from 'sentry/utils/discover/discoverQuery'; import {getDuration} from 'sentry/utils/formatters'; import {PERFORMANCE_SCORE_COLORS} from 'sentry/views/performance/browser/webVitals/utils/performanceScoreColors'; import { scoreToStatus, STATUS_TEXT, } from 'sentry/views/performance/browser/webVitals/utils/scoreToStatus'; import type { ProjectScore, WebVitals, } from 'sentry/views/performance/browser/webVitals/utils/types'; import {useReplaceFidWithInpSetting} from 'sentry/views/performance/browser/webVitals/utils/useReplaceFidWithInpSetting'; type Props = { onClick?: (webVital: WebVitals) => void; projectData?: TableData; projectScore?: ProjectScore; showTooltip?: boolean; transaction?: string; }; const WEB_VITALS_METERS_CONFIG = { lcp: { name: t('Largest Contentful Paint'), formatter: (value: number) => getFormattedDuration(value / 1000), }, fcp: { name: t('First Contentful Paint'), formatter: (value: number) => getFormattedDuration(value / 1000), }, fid: { name: t('First Input Delay'), formatter: (value: number) => getFormattedDuration(value / 1000), }, cls: { name: t('Cumulative Layout Shift'), formatter: (value: number) => Math.round(value * 100) / 100, }, ttfb: { name: t('Time To First Byte'), formatter: (value: number) => getFormattedDuration(value / 1000), }, }; const WEB_VITALS_METERS_CONFIG_WITH_INP = { lcp: WEB_VITALS_METERS_CONFIG.lcp, fcp: WEB_VITALS_METERS_CONFIG.fcp, inp: { name: t('Interaction to Next Paint'), formatter: (value: number) => getFormattedDuration(value / 1000), }, cls: WEB_VITALS_METERS_CONFIG.cls, ttfb: WEB_VITALS_METERS_CONFIG.ttfb, }; export default function WebVitalMeters({ onClick, projectData, projectScore, showTooltip = true, }: Props) { const theme = useTheme(); const shouldReplaceFidWithInp = useReplaceFidWithInpSetting(); if (!projectScore) { return null; } const webVitalsConfig = shouldReplaceFidWithInp ? WEB_VITALS_METERS_CONFIG_WITH_INP : WEB_VITALS_METERS_CONFIG; const webVitals = Object.keys(webVitalsConfig) as WebVitals[]; const colors = theme.charts.getColorPalette(3); return ( {webVitals.map((webVital, index) => { const webVitalExists = projectScore[`${webVital}Score`] !== undefined; const formattedMeterValueText = webVitalExists ? ( webVitalsConfig[webVital].formatter( projectData?.data?.[0]?.[`p75(measurements.${webVital})`] as number ) ) : ( ); const headerText = webVitalsConfig[webVital].name; const meterBody = ( {showTooltip && ( {tct( `The p75 [webVital] value and aggregate [webVital] score of your selected project(s). Scores and values may share some (but not perfect) correlation.`, { webVital: webVital.toUpperCase(), } )}
{t('Find out how performance scores are calculated here.')} } /> )} {headerText} {formattedMeterValueText}
); return ( webVitalExists && onClick?.(webVital)} clickable={webVitalExists} > {webVitalExists && } {webVitalExists && meterBody} {!webVitalExists && ( {meterBody} )} ); })}
); } export const getFormattedDuration = (value: number) => { return getDuration(value, value < 1 ? 0 : 2, true); }; const Container = styled('div')` margin-bottom: ${space(1)}; `; const Flex = styled('div')<{gap?: number}>` display: flex; flex-direction: row; justify-content: center; width: 100%; gap: ${p => (p.gap ? `${p.gap}px` : space(1))}; align-items: center; flex-wrap: wrap; `; const MeterBarContainer = styled('div')<{clickable?: boolean}>` flex: 1; position: relative; padding: 0; cursor: ${p => (p.clickable ? 'pointer' : 'default')}; min-width: 140px; `; const MeterBarBody = styled('div')` border: 1px solid ${p => p.theme.gray200}; border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0; border-bottom: none; padding: ${space(1)} 0 ${space(0.5)} 0; `; const MeterHeader = styled('div')` font-size: ${p => p.theme.fontSizeSmall}; color: ${p => p.theme.textColor}; display: inline-block; text-align: center; width: 100%; `; const MeterValueText = styled('div')` display: flex; justify-content: center; align-items: center; font-size: ${p => p.theme.headerFontSize}; color: ${p => p.theme.textColor}; flex: 1; text-align: center; `; function MeterBarFooter({score}: {score: number | undefined}) { if (score === undefined) { return ( {t('No Data')} ); } const status = scoreToStatus(score); return ( {STATUS_TEXT[status]} {score} ); } const MeterBarFooterContainer = styled('div')<{status: string}>` color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].normal]}; border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius}; background-color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].light]}; border: solid 1px ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].normal]}; font-size: ${p => p.theme.fontSizeExtraSmall}; padding: ${space(0.5)}; text-align: center; `; const NoValueContainer = styled('span')` color: ${p => p.theme.gray300}; font-size: ${p => p.theme.headerFontSize}; `; function NoValue() { return {' \u2014 '}; } const StyledTooltip = styled(Tooltip)` display: block; width: 100%; `; const StyledQuestionTooltip = styled(QuestionTooltip)` position: absolute; right: ${space(1)}; `; export const Dot = styled('span')<{color: string}>` display: inline-block; margin-right: ${space(1)}; border-radius: ${p => p.theme.borderRadius}; width: ${space(1)}; height: ${space(1)}; background-color: ${p => p.color}; `;