chrome-devtools-frontend
Version:
Chrome DevTools UI
356 lines (309 loc) • 12.8 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as i18n from '../../../core/i18n/i18n.js';
import * as Platform from '../../../core/platform/platform.js';
import * as CrUXManager from '../../../models/crux-manager/crux-manager.js';
import * as Trace from '../../../models/trace/trace.js';
import * as Buttons from '../../../ui/components/buttons/buttons.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';
import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js';
import cwvMetricsStyles from './cwvMetrics.css.js';
import {md} from './insights/Helpers.js';
import * as Insights from './insights/insights.js';
import {isFieldWorseThanLocal, NumberWithUnit} from './Utils.js';
const {html} = Lit.StaticHtml;
const UIStrings = {
/**
* @description title used for a metric value to tell the user about its score classification
* @example {INP} PH1
* @example {1.2s} PH2
* @example {poor} PH3
*/
metricScore: '{PH1}: {PH2} {PH3} score',
/**
* @description title used for a metric value to tell the user that the data is unavailable
* @example {INP} PH1
*/
metricScoreUnavailable: '{PH1}: unavailable',
/**
* @description Label denoting that metrics were observed in the field, from real use data (CrUX). Also denotes if from URL or Origin dataset.
* @example {URL} PH1
*/
fieldScoreLabel: 'Field ({PH1})',
/**
* @description Label for an option that selects the page's specific URL as opposed to it's entire origin/domain.
*/
urlOption: 'URL',
/**
* @description Label for an option that selects the page's entire origin/domain as opposed to it's specific URL.
*/
originOption: 'Origin',
/**
* @description Title for button that closes a warning popup.
*/
dismissTitle: 'Dismiss',
/**
* @description Title shown in a warning dialog when field metrics (collected from real users) is worse than the locally observed metrics.
*/
fieldMismatchTitle: 'Field & local metrics mismatch',
/**
* @description Text shown in a warning dialog when field metrics (collected from real users) is worse than the locally observed metrics.
* Asks user to use features such as throttling and device emulation.
*/
fieldMismatchNotice:
'There are many reasons why local and field metrics [may not match](https://web.dev/articles/lab-and-field-data-differences). ' +
'Adjust [throttling settings and device emulation](https://developer.chrome.com/docs/devtools/device-mode) to analyze traces more similar to the average user\'s environment.',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/CWVMetrics.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
interface LocalMetrics {
lcp: {value: Trace.Types.Timing.Micro, event: Trace.Types.Events.AnyLargestContentfulPaintCandidate}|null;
cls: {value: number, worstClusterEvent: Trace.Types.Events.Event|null};
inp: {value: Trace.Types.Timing.Micro, event: Trace.Types.Events.SyntheticInteractionPair}|null;
}
function getLocalMetrics(parsedTrace: Trace.TraceModel.ParsedTrace|null, insightSetKey: string|null): LocalMetrics|
null {
if (!parsedTrace || !insightSetKey) {
return null;
}
const insightSet = parsedTrace.insights?.get(insightSetKey);
if (!insightSet) {
return null;
}
const lcp = Trace.Insights.Common.getLCP(insightSet);
const cls = Trace.Insights.Common.getCLS(insightSet);
const inp = Trace.Insights.Common.getINP(insightSet);
return {lcp, cls, inp};
}
export function getFieldMetrics(parsedTrace: Trace.TraceModel.ParsedTrace|null, insightSetKey: string|null):
Trace.Insights.Common.CrUXFieldMetricResults|null {
if (!parsedTrace || !parsedTrace.metadata?.cruxFieldData || !insightSetKey) {
return null;
}
const insightSet = parsedTrace.insights?.get(insightSetKey);
if (!insightSet) {
return null;
}
let scope: CrUXManager.Scope|null = null;
try {
scope = CrUXManager.CrUXManager.instance().getSelectedScope();
} catch {
// test environment
}
const fieldMetricsResults =
Trace.Insights.Common.getFieldMetricsForInsightSet(insightSet, parsedTrace.metadata, scope);
if (!fieldMetricsResults) {
return null;
}
return fieldMetricsResults;
}
interface MetricsViewInput {
parsedTrace: Trace.TraceModel.ParsedTrace|null;
insightSetKey: string|null;
didDismissFieldMismatchNotice: boolean;
onDismisFieldMismatchNotice: () => void;
onClickMetric: (traceEvent: Trace.Types.Events.Event) => void;
skipBottomBorder: boolean;
}
type MetricsView = (input: MetricsViewInput, output: undefined, target: HTMLElement) => void;
const CWV_METRICS_VIEW: MetricsView = (input, _output, target) => {
const {
parsedTrace,
insightSetKey,
didDismissFieldMismatchNotice,
onDismisFieldMismatchNotice,
onClickMetric,
} = input;
const local = getLocalMetrics(parsedTrace, insightSetKey);
const field = getFieldMetrics(parsedTrace, insightSetKey);
const localValues = {
lcp: local?.lcp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(local?.lcp.value) : undefined,
inp: local?.inp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(local?.inp.value) : undefined,
};
const fieldValues = field && {
lcp: field.lcp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(field.lcp.value) : undefined,
inp: field.inp?.value !== undefined ? Trace.Helpers.Timing.microToMilli(field.inp.value) : undefined,
};
const showFieldMismatchNotice =
!didDismissFieldMismatchNotice && !!fieldValues && isFieldWorseThanLocal(localValues, fieldValues);
function renderMetricValue(
metric: 'LCP'|'CLS'|'INP', value: number|null, relevantEvent: Trace.Types.Events.Event|null): Lit.LitTemplate {
let valueText: string;
let valueDisplay: HTMLElement|string;
let classification;
if (value === null) {
valueText = valueDisplay = '-';
classification = Trace.Handlers.ModelHandlers.PageLoadMetrics.ScoreClassification.UNCLASSIFIED;
} else if (metric === 'LCP') {
const micros = value as Trace.Types.Timing.Micro;
const {text, element} = NumberWithUnit.formatMicroSecondsAsSeconds(micros);
valueText = text;
valueDisplay = element;
classification =
Trace.Handlers.ModelHandlers.PageLoadMetrics.scoreClassificationForLargestContentfulPaint(micros);
} else if (metric === 'CLS') {
valueText = valueDisplay = value ? value.toFixed(2) : '0';
classification = Trace.Handlers.ModelHandlers.LayoutShifts.scoreClassificationForLayoutShift(value);
} else if (metric === 'INP') {
const micros = value as Trace.Types.Timing.Micro;
const {text, element} = NumberWithUnit.formatMicroSecondsAsMillisFixed(micros);
valueText = text;
valueDisplay = element;
classification =
Trace.Handlers.ModelHandlers.UserInteractions.scoreClassificationForInteractionToNextPaint(micros);
} else {
Platform.TypeScriptUtilities.assertNever(metric, `Unexpected metric ${metric}`);
}
// NOTE: it is deliberate to use the same value for the title and
// aria-label; the aria-label is used to give more context to
// screen-readers, and the title is to aid users who may not know what
// the red/orange/green classification is, or those who are unable to
// easily distinguish the visual colour differences.
// clang-format off
const title = value !== null ?
i18nString(UIStrings.metricScore, {PH1: metric, PH2: valueText, PH3: classification}) :
i18nString(UIStrings.metricScoreUnavailable, {PH1: metric});
return html`
<button class="metric"
@click=${relevantEvent ? onClickMetric.bind(relevantEvent) : null}
title=${title}
aria-label=${title}
>
<div class="metric-value metric-value-${classification}">${valueDisplay}</div>
</button>
`;
// clang-format on
}
const lcpEl = renderMetricValue('LCP', local?.lcp?.value ?? null, local?.lcp?.event ?? null);
const inpEl = renderMetricValue('INP', local?.inp?.value ?? null, local?.inp?.event ?? null);
const clsEl = renderMetricValue('CLS', local?.cls?.value ?? null, local?.cls?.worstClusterEvent ?? null);
const localMetricsTemplateResult = html`
<div class="metrics-row">
<span>${lcpEl}</span>
<span>${inpEl}</span>
<span>${clsEl}</span>
<span class="row-label">Local</span>
</div>
${!field && input.skipBottomBorder ? Lit.nothing : html`<span class="row-border"></span>`}
`;
let fieldMetricsTemplateResult;
if (field) {
const {lcp, inp, cls} = field;
const lcpEl = renderMetricValue('LCP', lcp?.value ?? null, null);
const inpEl = renderMetricValue('INP', inp?.value ?? null, null);
const clsEl = renderMetricValue('CLS', cls?.value ?? null, null);
let scope = i18nString(UIStrings.originOption);
if (lcp?.pageScope === 'url' || inp?.pageScope === 'url') {
scope = i18nString(UIStrings.urlOption);
}
// clang-format off
fieldMetricsTemplateResult = html`
<div class="metrics-row">
<span>${lcpEl}</span>
<span>${inpEl}</span>
<span>${clsEl}</span>
<span class="row-label">${i18nString(UIStrings.fieldScoreLabel, {PH1: scope})}</span>
</div>
${input.skipBottomBorder ? Lit.nothing : html`<span class="row-border"></span>`}
`;
// clang-format on
}
let fieldIsDifferentEl;
if (showFieldMismatchNotice) {
// clang-format off
fieldIsDifferentEl = html`
<div class="field-mismatch-notice" jslog=${VisualLogging.section('timeline.insights.field-mismatch')}>
<h3>${i18nString(UIStrings.fieldMismatchTitle)}</h3>
<devtools-button
title=${i18nString(UIStrings.dismissTitle)}
.iconName=${'cross'}
.variant=${Buttons.Button.Variant.ICON}
.jslogContext=${'timeline.insights.dismiss-field-mismatch'}
@click=${onDismisFieldMismatchNotice}
></devtools-button>
<div class="field-mismatch-notice__body">${md(i18nString(UIStrings.fieldMismatchNotice))}</div>
</div>
`;
// clang-format on
}
const classes = {metrics: true, 'metrics--field': Boolean(fieldMetricsTemplateResult)};
const metricsTableEl = html`<div class=${Lit.Directives.classMap(classes)}>
<div class="metrics-row">
<span class="metric-label">LCP</span>
<span class="metric-label">INP</span>
<span class="metric-label">CLS</span>
<span class="row-label"></span>
</div>
${localMetricsTemplateResult}
${fieldMetricsTemplateResult}
</div>`;
Lit.render(
html`
<style>${cwvMetricsStyles}</style>
${metricsTableEl}
${fieldIsDifferentEl}
`,
target);
};
export interface CWVMetricsData {
insightSetKey: Trace.Types.Events.NavigationId|null;
parsedTrace: Trace.TraceModel.ParsedTrace|null;
}
export class CWVMetrics extends UI.Widget.Widget {
#view: MetricsView;
#data: CWVMetricsData = {
insightSetKey: null,
parsedTrace: null,
};
#didDismissFieldMismatchNotice = false;
#skipBottomBorder = false;
constructor(element?: HTMLElement, view: MetricsView = CWV_METRICS_VIEW) {
super(element, {useShadowDom: true});
this.#view = view;
}
set data(data: CWVMetricsData) {
this.#data = data;
this.requestUpdate();
}
get skipBottomBorder(): boolean {
return this.#skipBottomBorder;
}
set skipBottomBorder(x: boolean) {
if (x === this.#skipBottomBorder) {
return;
}
this.#skipBottomBorder = x;
this.requestUpdate();
}
#onClickMetric(traceEvent: Trace.Types.Events.Event): void {
this.element.dispatchEvent(new Insights.EventRef.EventReferenceClick(traceEvent));
}
#onDismisFieldMismatchNotice(): void {
this.#didDismissFieldMismatchNotice = true;
this.requestUpdate();
}
override performUpdate(): void {
const {
parsedTrace,
insightSetKey,
} = this.#data;
if (!parsedTrace?.insights || !insightSetKey || !(parsedTrace.insights instanceof Map)) {
return;
}
const insightSet = parsedTrace.insights.get(insightSetKey);
if (!insightSet) {
return;
}
const input: MetricsViewInput = {
parsedTrace,
insightSetKey,
didDismissFieldMismatchNotice: this.#didDismissFieldMismatchNotice,
onDismisFieldMismatchNotice: this.#onDismisFieldMismatchNotice.bind(this),
onClickMetric: this.#onClickMetric.bind(this),
skipBottomBorder: this.#skipBottomBorder,
};
this.#view(input, undefined, this.contentElement);
}
}