chrome-devtools-frontend
Version:
Chrome DevTools UI
264 lines (227 loc) • 8.73 kB
text/typescript
// Copyright 2024 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 AIAssistance from '../../../models/ai_assistance/ai_assistance.js';
import * as Trace from '../../../models/trace/trace.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Lit from '../../../ui/lit/lit.js';
import {CWVMetrics, getFieldMetrics} from './CWVMetrics.js';
import {shouldRenderForCategory} from './insights/Helpers.js';
import * as Insights from './insights/insights.js';
import type {ActiveInsight} from './Sidebar.js';
import sidebarSingleInsightSetStyles from './sidebarSingleInsightSet.css.js';
const {html} = Lit.StaticHtml;
/**
* Every insight (INCLUDING experimental ones).
*
* Order does not matter (but keep alphabetized).
*/
const INSIGHT_NAME_TO_COMPONENT = {
Cache: Insights.Cache.Cache,
CharacterSet: Insights.CharacterSet.CharacterSet,
CLSCulprits: Insights.CLSCulprits.CLSCulprits,
DocumentLatency: Insights.DocumentLatency.DocumentLatency,
DOMSize: Insights.DOMSize.DOMSize,
DuplicatedJavaScript: Insights.DuplicatedJavaScript.DuplicatedJavaScript,
FontDisplay: Insights.FontDisplay.FontDisplay,
ForcedReflow: Insights.ForcedReflow.ForcedReflow,
ImageDelivery: Insights.ImageDelivery.ImageDelivery,
INPBreakdown: Insights.INPBreakdown.INPBreakdown,
LCPDiscovery: Insights.LCPDiscovery.LCPDiscovery,
LCPBreakdown: Insights.LCPBreakdown.LCPBreakdown,
LegacyJavaScript: Insights.LegacyJavaScript.LegacyJavaScript,
ModernHTTP: Insights.ModernHTTP.ModernHTTP,
NetworkDependencyTree: Insights.NetworkDependencyTree.NetworkDependencyTree,
RenderBlocking: Insights.RenderBlocking.RenderBlocking,
SlowCSSSelector: Insights.SlowCSSSelector.SlowCSSSelector,
ThirdParties: Insights.ThirdParties.ThirdParties,
Viewport: Insights.Viewport.Viewport,
};
const UIStrings = {
/**
* @description Summary text for an expandable dropdown that contains all insights in a passing state.
* @example {4} PH1
*/
passedInsights: 'Passed insights ({PH1})',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/timeline/components/SidebarSingleInsightSet.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const {widget} = UI.Widget;
export interface SidebarSingleInsightSetData {
insightSetKey: Trace.Types.Events.NavigationId|null;
activeCategory: Trace.Insights.Types.InsightCategory;
activeInsight: ActiveInsight|null;
parsedTrace: Trace.TraceModel.ParsedTrace|null;
}
interface InsightData {
insightName: string;
model: Trace.Insights.Types.InsightModel;
}
interface ViewInput {
shownInsights: InsightData[];
passedInsights: InsightData[];
insightSetKey: Trace.Types.Events.NavigationId|null;
parsedTrace: Trace.TraceModel.ParsedTrace|null;
renderInsightComponent: (insightData: InsightData) => Lit.LitTemplate;
}
type View = (input: ViewInput, output: undefined, target: HTMLElement) => void;
const DEFAULT_VIEW: View = (input, output, target) => {
const {
shownInsights,
passedInsights,
insightSetKey,
parsedTrace,
renderInsightComponent,
} = input;
function renderMetrics(): Lit.LitTemplate {
if (!insightSetKey || !parsedTrace) {
return Lit.nothing;
}
return html`${widget(CWVMetrics, {data: {insightSetKey, parsedTrace}})}`;
}
function renderInsights(): Lit.LitTemplate {
const shownInsightTemplates = shownInsights.map(renderInsightComponent);
const passedInsightsTemplates = passedInsights.map(renderInsightComponent);
// clang-format off
return html`
${shownInsightTemplates}
${passedInsightsTemplates.length ? html`
<details class="passed-insights-section">
<summary>${i18nString(UIStrings.passedInsights, {
PH1: passedInsightsTemplates.length,
})}</summary>
${passedInsightsTemplates}
</details>
` : Lit.nothing}
`;
// clang-format on
}
// clang-format off
Lit.render(html`
<style>${sidebarSingleInsightSetStyles}</style>
<div class="navigation">
${renderMetrics()}
${renderInsights()}
</div>
`, target);
// clang-format on
};
export class SidebarSingleInsightSet extends UI.Widget.Widget {
#view: View;
#isActiveInsightHighlighted = false;
#activeHighlightTimeout = -1;
#data: SidebarSingleInsightSetData = {
insightSetKey: null,
activeCategory: Trace.Insights.Types.InsightCategory.ALL,
activeInsight: null,
parsedTrace: null,
};
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element, {useShadowDom: true});
this.#view = view;
}
set data(data: SidebarSingleInsightSetData) {
this.#data = data;
this.requestUpdate();
}
override willHide(): void {
super.willHide();
window.clearTimeout(this.#activeHighlightTimeout);
}
async highlightActiveInsight(): Promise<void> {
window.clearTimeout(this.#activeHighlightTimeout);
this.#isActiveInsightHighlighted = false;
this.requestUpdate();
await this.updateComplete;
this.#isActiveInsightHighlighted = true;
this.requestUpdate();
this.#activeHighlightTimeout = window.setTimeout(() => {
this.#isActiveInsightHighlighted = false;
this.requestUpdate();
}, 2_000);
}
static categorizeInsights(
insightSets: Trace.Insights.Types.TraceInsightSets|null,
insightSetKey: string,
activeCategory: Trace.Insights.Types.InsightCategory,
): {shownInsights: InsightData[], passedInsights: InsightData[]} {
if (!insightSets || !(insightSets instanceof Map)) {
return {shownInsights: [], passedInsights: []};
}
const insightSet = insightSets.get(insightSetKey);
if (!insightSet) {
return {shownInsights: [], passedInsights: []};
}
const shownInsights: InsightData[] = [];
const passedInsights: InsightData[] = [];
for (const [insightName, model] of Object.entries(insightSet.model)) {
if (!model || !shouldRenderForCategory({activeCategory, insightCategory: model.category})) {
continue;
}
if (model.state === 'pass') {
passedInsights.push({insightName, model});
} else {
shownInsights.push({insightName, model});
}
}
return {shownInsights, passedInsights};
}
#renderInsightComponent(
insightSet: Trace.Insights.Types.InsightSet, insightData: InsightData,
fieldMetrics: Trace.Insights.Common.CrUXFieldMetricResults|null): Lit.LitTemplate {
if (!this.#data.parsedTrace) {
return Lit.nothing;
}
const {insightName, model} = insightData;
const activeInsight = this.#data.activeInsight;
const agentFocus = AIAssistance.AIContext.AgentFocus.fromInsight(this.#data.parsedTrace, model);
const isActiveInsight = activeInsight?.model === model;
const componentClass = INSIGHT_NAME_TO_COMPONENT[insightName as keyof typeof INSIGHT_NAME_TO_COMPONENT];
const widgetConfig = {
selected: isActiveInsight,
// The `model` passed in as a parameter is the base type, but since
// `componentClass` is the union of every derived insight component, the
// `model` for the widget config is the union of every model. That can't be
// satisfied, so disable typescript.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
model: model as any,
bounds: insightSet.bounds,
insightSetKey: insightSet.id,
agentFocus,
fieldMetrics,
};
// clang-format off
return html`<devtools-widget class="insight-component-widget" ?highlight-insight=${isActiveInsight && this.#isActiveInsightHighlighted}
${widget(componentClass, widgetConfig)}
></devtools-widget>`;
// clang-format on
}
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 field = getFieldMetrics(parsedTrace, insightSetKey);
const {shownInsights, passedInsights} = SidebarSingleInsightSet.categorizeInsights(
parsedTrace.insights,
insightSetKey,
this.#data.activeCategory,
);
const input: ViewInput = {
shownInsights,
passedInsights,
insightSetKey,
parsedTrace,
renderInsightComponent: insightData => this.#renderInsightComponent(insightSet, insightData, field),
};
this.#view(input, undefined, this.contentElement);
}
}