UNPKG

chrome-devtools-frontend

Version:
421 lines (387 loc) • 15 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 '../../../ui/legacy/components/data_grid/data_grid.js'; import * as Common from '../../../core/common/common.js'; import * as i18n from '../../../core/i18n/i18n.js'; import * as SDK from '../../../core/sdk/sdk.js'; import type * as Protocol from '../../../generated/protocol.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 adsViewStyles from './adsView.css.js'; const {html} = Lit; const {bindToSetting} = UI.UIUtils; const UIStrings = { /** * @description Title for the metrics table. */ metrics: 'Metrics', /** * @description Title for a metric showing the percentage of the viewport covered by ads. */ viewportAdDensity: 'Viewport ad density', /** * @description Title for a metric showing the number of ads in the viewport. */ viewportAdCount: 'Viewport ad count', /** * @description Title for a metric showing the total CPU usage by ads. */ totalCpuUsage: 'Total CPU usage by ads', /** * @description Title for a metric showing the total network usage by ads. */ totalNetworkUsage: 'Total network usage by ads', /** * @description Subtext showing the average value of a metric. * @example {5.00%} PH1 */ average: '(Average: {PH1})', /** * @description Title for the ad iframes table. * @example {3} PH1 */ adIframesTitle: 'Ad iframes (total {PH1})', /** * @description Text to display when a value is not available. */ notAvailable: 'N/A', /** * @description Text to display when a frame has no name/id. */ unnamed: '<unnamed>', /** * @description Title for the Element Id column in the ad iframes table. */ elementId: 'Element ID', /** * @description Title for the Initial origin column in the ad iframes table. */ initialOrigin: 'Initial origin', /** * @description Title for the CPU column in the ad iframes table. */ cpu: 'CPU', /** * @description Title for the Network column in the ad iframes table. */ network: 'Network', /** * @description Accessible name for the ad iframes table. */ adIframes: 'Ad iframes', /** * @description Title for the settings section. */ settings: 'Settings', /** * @description The name of a checkbox setting. This setting highlights the * rendering elements for ads that are found on the page. */ highlightAds: 'Highlight ads', /** * @description Explanation text for the 'Highlight ads' setting. */ highlightsElementsRedDetectedToBe: 'Highlights elements (red) detected to be ads.', } as const; const str_ = i18n.i18n.registerUIStrings('panels/application/components/AdsView.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); /** * @description Data for a single row in the ad iframes table. */ interface AdFrameNodeData { elementId: string; initialOrigin: string; networkBytes: string; cpuTime: string; } export interface ViewInput { metrics: Protocol.Ads.AdMetrics; adFrames: AdFrameNodeData[]; } export type View = (input: ViewInput, output: undefined, target: HTMLElement|DocumentFragment) => void; const formatMetric = (val: number|undefined, formatter: (val: number) => string): string => { if (val === undefined || val === -1) { return i18nString(UIStrings.notAvailable); } return formatter(val); }; const formatCpu = (val: number|undefined): string => { return formatMetric(val, (v: number): string => i18n.TimeUtilities.millisToString(v)); }; const formatNetwork = (val: number|undefined): string => { return formatMetric(val, (v: number): string => i18n.ByteUtilities.bytesToString(v)); }; const DEFAULT_VIEW: View = (input, output, target) => { const metrics = input.metrics; const formatValue = (val: number, isPercentage: boolean): string => { return formatMetric(val, (v: number): string => { if (isPercentage) { return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { style: 'percent', maximumFractionDigits: 0, }) .format(v / 100); } return new Intl.NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale).format(v); }); }; const formatAverage = (val: number|undefined, isPercentage: boolean): string => { return formatMetric(val, (v: number): string => { if (isPercentage) { return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(v / 100); } return new Intl .NumberFormat(i18n.DevToolsLocale.DevToolsLocale.instance().locale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }) .format(v); }); }; // clang-format off Lit.render(html` <style>${adsViewStyles}</style> <div class="ads-view-container" jslog=${VisualLogging.pane('ads')}> <div class="metrics-title">${i18nString(UIStrings.metrics)}</div> <dl class="metrics-container"> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.viewportAdDensity)}</dt> <dd class="metric-value"> <span>${formatValue(metrics.viewportAdDensityByArea, true)}</span> <span class="metric-average">${i18nString(UIStrings.average, { PH1: formatAverage(metrics.averageViewportAdDensityByArea, true), })}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.viewportAdCount)}</dt> <dd class="metric-value"> <span>${formatValue(metrics.viewportAdCount, false)}</span> <span class="metric-average">${i18nString(UIStrings.average, { PH1: formatAverage(metrics.averageViewportAdCount, false), })}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.totalCpuUsage)}</dt> <dd class="metric-value"> <span>${formatCpu(metrics.totalAdCpuTime)}</span> </dd> </div> <div class="metric-box"> <dt class="metric-title">${i18nString(UIStrings.totalNetworkUsage)}</dt> <dd class="metric-value"> <span>${formatNetwork(metrics.totalAdNetworkBytes)}</span> </dd> </div> </dl> <div class="ad-frames-title">${i18nString(UIStrings.adIframesTitle, {PH1: input.adFrames.length})}</div> <div class="ad-frames-container"> <devtools-data-grid striped resize="last" class="ad-frames-data-grid" name=${i18nString(UIStrings.adIframes)}> <table> <tr> <th id="elementId" weight="1">${i18nString(UIStrings.elementId)}</th> <th id="initialOrigin" weight="2">${i18nString(UIStrings.initialOrigin)}</th> <th id="cpuTime" weight="1">${i18nString(UIStrings.cpu)}</th> <th id="networkBytes" weight="1">${i18nString(UIStrings.network)}</th> </tr> ${input.adFrames.map(frame => html` <tr> <td title=${frame.elementId}>${frame.elementId}</td> <td title=${frame.initialOrigin}>${frame.initialOrigin}</td> <td title=${frame.cpuTime}>${frame.cpuTime}</td> <td title=${frame.networkBytes}>${frame.networkBytes}</td> </tr> `)} </table> </devtools-data-grid> </div> <div class="settings-title">${i18nString(UIStrings.settings)}</div> <devtools-checkbox class="setting-container" ${bindToSetting(Common.Settings.Settings.instance().moduleSetting('show-ad-highlights'))}> <div class="setting-text-container"> <div class="setting-label">${i18nString(UIStrings.highlightAds)}</div> <div class="setting-explanation">${i18nString(UIStrings.highlightsElementsRedDetectedToBe)}</div> </div> </devtools-checkbox> </div> `, target); // clang-format on }; export class AdsView extends UI.Widget.Widget { #currentMetrics: Protocol.Ads.AdMetrics; #pollTimer?: number; #isPolling = false; #pollSessionId = 0; #view: View; readonly #adFrames = new Map<Protocol.Page.FrameId, Protocol.Ads.AdFrameData>(); readonly #adIframeElementIds = new Map<Protocol.Page.FrameId, string|null>(); readonly #fetchingElementIds = new Set<Protocol.Page.FrameId>(); constructor(view: View = DEFAULT_VIEW) { super({useShadowDom: true}); this.#view = view; this.#currentMetrics = { viewportAdDensityByArea: 0, averageViewportAdDensityByArea: 0, viewportAdCount: 0, averageViewportAdCount: 0, totalAdCpuTime: 0, totalAdNetworkBytes: 0, updateAdFrames: [], removeAdFrames: [], }; this.requestUpdate(); } override wasShown(): void { super.wasShown(); this.#startPolling(); SDK.TargetManager.TargetManager.instance().addModelListener(SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); } override willHide(): void { this.#stopPolling(); SDK.TargetManager.TargetManager.instance().removeModelListener(SDK.ResourceTreeModel.ResourceTreeModel, SDK.ResourceTreeModel.Events.PrimaryPageChanged, this.#onPrimaryPageChanged, this); super.willHide(); } #startPolling(): void { if (this.#isPolling) { return; } this.#isPolling = true; this.#pollSessionId++; void this.#pollMetrics(this.#pollSessionId); } #stopPolling(): void { this.#isPolling = false; if (this.#pollTimer !== undefined) { window.clearTimeout(this.#pollTimer); this.#pollTimer = undefined; } } async #pollMetrics(sessionId: number): Promise<void> { if (!this.#isPolling || this.#pollSessionId !== sessionId) { return; } const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget(); if (target) { const adsAgent = target.adsAgent(); if (adsAgent) { const response = await adsAgent.invoke_getAdMetrics(); if (!this.#isPolling || this.#pollSessionId !== sessionId) { return; } if (!response.getError()) { this.#currentMetrics = response.metrics; this.#processAdFrames(response.metrics); this.requestUpdate(); } } } if (this.#isPolling && this.#pollSessionId === sessionId) { this.#pollTimer = window.setTimeout(() => this.#pollMetrics(sessionId), 500); } } #processAdFrames(metrics: Protocol.Ads.AdMetrics): void { // Drop removed frames from the local cache. for (const frameId of metrics.removeAdFrames || []) { this.#adFrames.delete(frameId); this.#adIframeElementIds.delete(frameId); } // Merge partial updates into the local cache. for (const frame of metrics.updateAdFrames || []) { const frameId = frame.frameId; const existingFrame = this.#adFrames.get(frameId) || {} as Protocol.Ads.AdFrameData; // Object Spread / Protocol Undefined Behavior // To reduce the payload size, the C++ backend only sends the 'initialOrigin' field // when it changes since the last sent message for the same frame. Because // the parsed JSON frame won't have the 'initialOrigin' key if it hasn't changed, // spreading it over existingFrame won't overwrite existingFrame.initialOrigin with // undefined. const newFrame = {...existingFrame, ...frame} as Protocol.Ads.AdFrameData; this.#adFrames.set(frameId, newFrame); } // Asynchronously fetch Element IDs for newly tracked frames. // Duplicate requests are prevented by #fetchingElementIds. for (const frameId of this.#adFrames.keys()) { if (!this.#adIframeElementIds.has(frameId) && !this.#fetchingElementIds.has(frameId)) { this.#fetchingElementIds.add(frameId); void this.#fetchIframeElementId(frameId) .then(elementId => { if (this.#adFrames.has(frameId) && elementId !== undefined) { this.#adIframeElementIds.set(frameId, elementId); } }) .catch(() => {}) .finally(() => { this.#fetchingElementIds.delete(frameId); this.requestUpdate(); }); } } } async #fetchIframeElementId(frameId: Protocol.Page.FrameId): Promise<string|null|undefined> { const frame = SDK.FrameManager.FrameManager.instance().getFrame(frameId); if (!frame) { return undefined; } const domModel = frame.resourceTreeModel().target().model(SDK.DOMModel.DOMModel); if (!domModel) { return undefined; } const deferredNode = await domModel.getOwnerNodeForFrame(frameId); if (deferredNode) { const node = await deferredNode.resolvePromise(); return node?.getAttribute('id') || null; } return null; } #onPrimaryPageChanged(): void { this.#currentMetrics = { viewportAdDensityByArea: 0, averageViewportAdDensityByArea: 0, viewportAdCount: 0, averageViewportAdCount: 0, totalAdCpuTime: 0, totalAdNetworkBytes: 0, updateAdFrames: [], removeAdFrames: [], }; this.#adFrames.clear(); this.#adIframeElementIds.clear(); this.#fetchingElementIds.clear(); this.requestUpdate(); } override performUpdate(): void { const adFramesArray: AdFrameNodeData[] = []; for (const [frameId, frame] of this.#adFrames) { // The table displays the resolved ID, or an <unnamed> placeholder if the // element lacks an ID attribute, or an empty string while pending. const elementIdText = this.#adIframeElementIds.has(frameId) ? (this.#adIframeElementIds.get(frameId) || i18nString(UIStrings.unnamed)) : ''; adFramesArray.push({ elementId: elementIdText, initialOrigin: frame.initialOrigin || '', cpuTime: formatCpu(frame.cpuTime), networkBytes: formatNetwork(frame.networkBytes), }); } const viewInput: ViewInput = { metrics: this.#currentMetrics, adFrames: adFramesArray, }; this.#view(viewInput, undefined, this.contentElement); } }