123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- 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 (
- <Container>
- <Flex>
- {webVitals.map((webVital, index) => {
- const webVitalExists = projectScore[`${webVital}Score`] !== undefined;
- const formattedMeterValueText = webVitalExists ? (
- webVitalsConfig[webVital].formatter(
- projectData?.data?.[0]?.[`p75(measurements.${webVital})`] as number
- )
- ) : (
- <NoValue />
- );
- const headerText = webVitalsConfig[webVital].name;
- const meterBody = (
- <Fragment>
- <MeterBarBody>
- {showTooltip && (
- <StyledQuestionTooltip
- isHoverable
- size="xs"
- title={
- <span>
- {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(),
- }
- )}
- <br />
- <ExternalLink href="https://docs.sentry.io/product/performance/web-vitals/#performance-score">
- {t('Find out how performance scores are calculated here.')}
- </ExternalLink>
- </span>
- }
- />
- )}
- <MeterHeader>{headerText}</MeterHeader>
- <MeterValueText>
- <Dot color={colors[index]} />
- {formattedMeterValueText}
- </MeterValueText>
- </MeterBarBody>
- <MeterBarFooter score={projectScore[`${webVital}Score`]} />
- </Fragment>
- );
- return (
- <MeterBarContainer
- key={webVital}
- onClick={() => webVitalExists && onClick?.(webVital)}
- clickable={webVitalExists}
- >
- {webVitalExists && <InteractionStateLayer />}
- {webVitalExists && meterBody}
- {!webVitalExists && (
- <StyledTooltip
- title={tct('No [webVital] data found in this project.', {
- webVital: webVital.toUpperCase(),
- })}
- >
- {meterBody}
- </StyledTooltip>
- )}
- </MeterBarContainer>
- );
- })}
- </Flex>
- </Container>
- );
- }
- 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 (
- <MeterBarFooterContainer status="none">{t('No Data')}</MeterBarFooterContainer>
- );
- }
- const status = scoreToStatus(score);
- return (
- <MeterBarFooterContainer status={status}>
- {STATUS_TEXT[status]} {score}
- </MeterBarFooterContainer>
- );
- }
- 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 <NoValueContainer>{' \u2014 '}</NoValueContainer>;
- }
- 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};
- `;
|