UNPKG

chrome-devtools-frontend

Version:
356 lines (309 loc) • 12.8 kB
// 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); } }