chrome-devtools-frontend
Version:
Chrome DevTools UI
421 lines (387 loc) • 15 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 '../../../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);
}
}