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/duration/getDuration'; import {VITAL_DESCRIPTIONS} from 'sentry/views/insights/browser/webVitals/components/webVitalDescription'; import {MODULE_DOC_LINK} from 'sentry/views/insights/browser/webVitals/settings'; import type { ProjectScore, WebVitals, } from 'sentry/views/insights/browser/webVitals/types'; import {PERFORMANCE_SCORE_COLORS} from 'sentry/views/insights/browser/webVitals/utils/performanceScoreColors'; import { scoreToStatus, STATUS_TEXT, } from 'sentry/views/insights/browser/webVitals/utils/scoreToStatus'; 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), }, inp: { name: t('Interaction to Next Paint'), 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), }, }; export default function WebVitalMeters({ onClick, projectData, projectScore, showTooltip = true, }: Props) { const theme = useTheme(); if (!projectScore) { return null; } const webVitalsConfig = WEB_VITALS_METERS_CONFIG; const webVitals = Object.keys(webVitalsConfig) as WebVitals[]; const colors = theme.charts.getColorPalette(3) ?? []; const renderVitals = () => { return webVitals.map((webVital, index) => { const webVitalKey = `p75(measurements.${webVital})`; const score = projectScore[`${webVital}Score`]; const meterValue = projectData?.data?.[0]?.[webVitalKey] as number; if (!score) { return null; } return ( ); }); }; return ( {renderVitals()} ); } type VitalMeterProps = { color: string; meterValue: number | undefined; score: number | undefined; showTooltip: boolean; webVital: WebVitals; isAggregateMode?: boolean; onClick?: (webVital: WebVitals) => void; }; export function VitalMeter({ webVital, showTooltip, score, meterValue, color, onClick, isAggregateMode = true, }: VitalMeterProps) { const webVitalsConfig = WEB_VITALS_METERS_CONFIG; const webVitalExists = score !== undefined; const formattedMeterValueText = webVitalExists && meterValue ? ( webVitalsConfig[webVital].formatter(meterValue) ) : ( ); const webVitalKey = `measurements.${webVital}`; const {shortDescription} = VITAL_DESCRIPTIONS[webVitalKey]; const headerText = webVitalsConfig[webVital].name; const meterBody = ( {showTooltip && ( {shortDescription}
{t('Find out how performance scores are calculated here.')} } /> )} {headerText} {formattedMeterValueText}
); return ( ); } type VitalContainerProps = { meterBody: React.ReactNode; webVital: WebVitals; webVitalExists: boolean; isAggregateMode?: boolean; onClick?: (webVital: WebVitals) => void; }; function VitalContainer({ webVital, webVitalExists, meterBody, onClick, isAggregateMode = true, }: VitalContainerProps) { 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}>` background-color: ${p => p.theme.background}; 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}; font-weight: ${p => p.theme.fontWeightBold}; 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].border]}; 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}; `; // A compressed version of the VitalMeter component used in the trace context panel type VitalPillProps = Omit< VitalMeterProps, 'showTooltip' | 'isAggregateMode' | 'onClick' | 'color' >; export function VitalPill({webVital, score, meterValue}: VitalPillProps) { const status = score !== undefined ? scoreToStatus(score) : 'none'; const webVitalExists = score !== undefined; const webVitalsConfig = WEB_VITALS_METERS_CONFIG; const formattedMeterValueText = webVitalExists && meterValue ? ( webVitalsConfig[webVital].formatter(meterValue) ) : ( ); const tooltipText = VITAL_DESCRIPTIONS[`measurements.${webVital}`]; return ( {`${webVital ? webVital.toUpperCase() : ''} (${STATUS_TEXT[status] ?? 'N/A'})`} {formattedMeterValueText} ); } const VitalPillContainer = styled('div')` display: flex; flex-direction: row; width: 100%; height: 30px; `; const VitalPillName = styled('div')<{status: string}>` display: flex; align-items: center; position: relative; height: 100%; padding: 0 ${space(1)}; border: solid 1px ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].border]}; border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius}; background-color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].light]}; color: ${p => p.theme[PERFORMANCE_SCORE_COLORS[p.status].normal]}; font-size: ${p => p.theme.fontSizeSmall}; font-weight: ${p => p.theme.fontWeightBold}; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: ${space(0.25)}; text-decoration-thickness: 1px; cursor: pointer; `; const VitalPillValue = styled('div')` display: flex; flex: 1; align-items: center; justify-content: flex-end; height: 100%; padding: 0 ${space(0.5)}; border: 1px solid ${p => p.theme.gray200}; border-left: none; border-radius: 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0; background: ${p => p.theme.background}; color: ${p => p.theme.textColor}; font-size: ${p => p.theme.fontSizeLarge}; `;