chrome-devtools-frontend
Version:
Chrome DevTools UI
770 lines (714 loc) • 25.9 kB
text/typescript
// Copyright 2017 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 Common from '../../core/common/common.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import * as Platform from '../../core/platform/platform.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as UI from '../../ui/legacy/legacy.js';
import * as ThemeSupport from '../../ui/legacy/theme_support/theme_support.js';
import {Directives, html, render, type TemplateResult} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import performanceMonitorStyles from './performanceMonitor.css.js';
const UIStrings = {
/**
* @description Aria accessible name in Performance Monitor of the Performance monitor tab
*/
graphsDisplayingARealtimeViewOf: 'Graphs displaying a real-time view of performance metrics',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
paused: 'Paused',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
cpuUsage: 'CPU usage',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
jsHeapSize: 'JS heap size',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
domNodes: 'DOM Nodes',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
jsEventListeners: 'JS event listeners',
/**
* @description Text for documents, a type of resources
*/
documents: 'Documents',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
documentFrames: 'Document Frames',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
layoutsSec: 'Layouts / sec',
/**
* @description Text in Performance Monitor of the Performance monitor tab
*/
styleRecalcsSec: 'Style recalcs / sec',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/performance_monitor/PerformanceMonitor.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const {widgetConfig} = UI.Widget;
const {classMap, ref} = Directives;
interface PerformanceMonitorInput {
onMetricChanged: (metricName: string, active: boolean) => void;
chartsInfo: ChartInfo[];
metrics?: Map<string, number>;
width: number;
height: number;
suspended: boolean;
}
interface PerformanceMonitorOutput {
graphRenderingContext: CanvasRenderingContext2D|null;
width: number;
}
type PerformanceMonitorView = (input: PerformanceMonitorInput, output: PerformanceMonitorOutput, target: HTMLElement) =>
void;
const DEFAULT_VIEW: PerformanceMonitorView = (input, output, target) => {
// clang-format off
render(html`
<devtools-widget .widgetConfig=${widgetConfig(ControlPane, {
onMetricChanged: input.onMetricChanged,
chartsInfo: input.chartsInfo,
metrics: input.metrics
})} class=${classMap({suspended: input.suspended})}></devtools-widget>
<div class="perfmon-chart-container ${classMap({suspended: input.suspended})}">
<canvas tabindex="-1" aria-label=${i18nString(UIStrings.graphsDisplayingARealtimeViewOf)}
.width=${Math.round(input.width * window.devicePixelRatio)} .height=${input.height}
style="height:${input.height / window.devicePixelRatio}px" ${ref(e => {
if (e) {
const canvas = e as HTMLCanvasElement;
output.graphRenderingContext = canvas.getContext('2d');
output.width = canvas.offsetWidth;
}
})}>
</canvas>
</div>
${input.suspended ? html`
<div class="perfmon-chart-suspend-overlay fill">
<div>${i18nString(UIStrings.paused)}</div>
</div>` : ''}`,
target);
// clang-format on
};
export class PerformanceMonitorImpl extends UI.Widget.HBox implements
SDK.TargetManager.SDKModelObserver<SDK.PerformanceMetricsModel.PerformanceMetricsModel> {
private view: PerformanceMonitorView;
private chartInfos: ChartInfo[] = [];
private activeCharts = new Set<string>();
private metricsBuffer: Array<{timestamp: number, metrics: Map<string, number>}>;
private readonly pixelsPerMs: number;
private pollIntervalMs: number;
private readonly scaleHeight: number;
private graphHeight: number;
private gridColor: string;
private animationId!: number;
private width!: number;
private height!: number;
private model?: SDK.PerformanceMetricsModel.PerformanceMetricsModel|null;
private pollTimer?: number;
private metrics?: Map<string, number>;
private suspended = false;
private graphRenderingContext: CanvasRenderingContext2D|null = null;
constructor(pollIntervalMs = 500, view = DEFAULT_VIEW) {
super({
jslog: `${VisualLogging.panel('performance.monitor').track({resize: true})}`,
useShadowDom: true,
});
this.view = view;
this.registerRequiredCSS(performanceMonitorStyles);
this.metricsBuffer = [];
/** @constant */
this.pixelsPerMs = 10 / 1000;
/** @constant */
this.pollIntervalMs = pollIntervalMs;
/** @constant */
this.scaleHeight = 16;
/** @constant */
this.graphHeight = 90;
this.gridColor = ThemeSupport.ThemeSupport.instance().getComputedValue('--divider-line');
SDK.TargetManager.TargetManager.instance().observeModels(SDK.PerformanceMetricsModel.PerformanceMetricsModel, this);
}
private onMetricStateChanged(metricName: string, active: boolean): void {
if (active) {
this.activeCharts.add(metricName);
} else {
this.activeCharts.delete(metricName);
}
this.recalcChartHeight();
}
override wasShown(): void {
if (!this.model) {
return;
}
this.chartInfos = this.createChartInfos();
const themeSupport = ThemeSupport.ThemeSupport.instance();
themeSupport.addEventListener(ThemeSupport.ThemeChangeEvent.eventName, () => {
// instantiateMetricData sets the colors for the metrics, which we need
// to re-evaluate when the theme changes before re-drawing the canvas.
this.chartInfos = this.createChartInfos();
this.requestUpdate();
});
SDK.TargetManager.TargetManager.instance().addEventListener(
SDK.TargetManager.Events.SUSPEND_STATE_CHANGED, this.suspendStateChanged, this);
void this.model.enable();
this.suspendStateChanged();
this.requestUpdate();
}
override willHide(): void {
if (!this.model) {
return;
}
SDK.TargetManager.TargetManager.instance().removeEventListener(
SDK.TargetManager.Events.SUSPEND_STATE_CHANGED, this.suspendStateChanged, this);
this.stopPolling();
void this.model.disable();
}
override performUpdate(): void {
const input = {
onMetricChanged: this.onMetricStateChanged.bind(this),
chartsInfo: this.chartInfos,
metrics: this.metrics,
width: this.width,
height: this.height,
suspended: this.suspended,
};
const output = {graphRenderingContext: null, width: 0};
this.view(input, output, this.contentElement);
this.graphRenderingContext = output.graphRenderingContext;
this.width = output.width;
this.draw();
}
modelAdded(model: SDK.PerformanceMetricsModel.PerformanceMetricsModel): void {
if (model.target() !== SDK.TargetManager.TargetManager.instance().primaryPageTarget()) {
return;
}
this.model = model;
if (this.isShowing()) {
this.wasShown();
}
}
modelRemoved(model: SDK.PerformanceMetricsModel.PerformanceMetricsModel): void {
if (this.model !== model) {
return;
}
if (this.isShowing()) {
this.willHide();
}
this.model = null;
}
private suspendStateChanged(): void {
const suspended = SDK.TargetManager.TargetManager.instance().allTargetsSuspended();
if (suspended) {
this.stopPolling();
} else {
this.startPolling();
}
this.suspended = suspended;
this.requestUpdate();
}
private startPolling(): void {
this.pollTimer = window.setInterval(() => this.poll(), this.pollIntervalMs);
this.onResize();
const animate = (): void => {
this.draw();
this.animationId = this.contentElement.window().requestAnimationFrame(() => {
animate();
});
};
animate();
}
private stopPolling(): void {
window.clearInterval(this.pollTimer);
this.contentElement.window().cancelAnimationFrame(this.animationId);
this.metricsBuffer = [];
}
private async poll(): Promise<void> {
if (!this.model) {
return;
}
const data = await this.model.requestMetrics();
const timestamp = data.timestamp;
const metrics = data.metrics;
this.metricsBuffer.push({timestamp, metrics});
const millisPerWidth = this.width / this.pixelsPerMs;
// Multiply by 2 as the pollInterval has some jitter and to have some extra samples if window is resized.
const maxCount = Math.ceil(millisPerWidth / this.pollIntervalMs * 2);
if (this.metricsBuffer.length > maxCount * 2) // Multiply by 2 to have a hysteresis.
{
this.metricsBuffer.splice(0, this.metricsBuffer.length - maxCount);
}
this.metrics = metrics;
this.requestUpdate();
}
private draw(): void {
if (!this.graphRenderingContext) {
return;
}
const ctx = this.graphRenderingContext;
ctx.save();
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.clearRect(0, 0, this.width, this.height);
ctx.save();
ctx.translate(0, this.scaleHeight); // Reserve space for the scale bar.
for (const chartInfo of this.chartInfos) {
if (!this.activeCharts.has(chartInfo.metrics[0].name)) {
continue;
}
this.drawChart(ctx, chartInfo, this.graphHeight);
ctx.translate(0, this.graphHeight);
}
ctx.restore();
this.drawHorizontalGrid(ctx);
ctx.restore();
}
private drawHorizontalGrid(ctx: CanvasRenderingContext2D): void {
const labelDistanceSeconds = 10;
const lightGray = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-inverted-opacity-2');
ctx.font = '10px ' + Host.Platform.fontFamily();
ctx.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-inverted-opacity-50');
const currentTime = Date.now() / 1000;
for (let sec = Math.ceil(currentTime);; --sec) {
const x = this.width - ((currentTime - sec) * 1000 - this.pollIntervalMs) * this.pixelsPerMs;
if (x < -50) {
break;
}
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, this.height);
if (sec >= 0 && sec % labelDistanceSeconds === 0) {
ctx.fillText(new Date(sec * 1000).toLocaleTimeString(), x + 4, 12);
}
ctx.strokeStyle = sec % labelDistanceSeconds ? lightGray : this.gridColor;
ctx.stroke();
}
}
private drawChart(ctx: CanvasRenderingContext2D, chartInfo: ChartInfo, height: number): void {
ctx.save();
ctx.rect(0, 0, this.width, height);
ctx.clip();
const bottomPadding = 8;
const extraSpace = 1.05;
const max = this.calcMax(chartInfo) * extraSpace;
const stackedChartBaseLandscape = chartInfo.stacked ? new Map() : null;
const paths = [];
for (let i = chartInfo.metrics.length - 1; i >= 0; --i) {
const metricInfo = chartInfo.metrics[i];
paths.push({
path: this.buildMetricPath(
chartInfo, metricInfo, height - bottomPadding, max, i ? stackedChartBaseLandscape : null),
color: metricInfo.color,
});
}
const backgroundColor =
Common.Color.parse(ThemeSupport.ThemeSupport.instance().getComputedValue('--sys-color-cdt-base-container'))
?.asLegacyColor();
if (backgroundColor) {
for (const path of paths.reverse()) {
const color = path.color;
ctx.save();
const parsedColor = Common.Color.parse(color);
if (!parsedColor) {
continue;
}
ctx.fillStyle = backgroundColor.blendWith(parsedColor.setAlpha(0.2).asLegacyColor()).asString() || '';
ctx.fill(path.path);
ctx.strokeStyle = color;
ctx.lineWidth = 0.5;
ctx.stroke(path.path);
ctx.restore();
}
}
ctx.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-inverted-opacity-50');
ctx.font = `10px ${Host.Platform.fontFamily()}`;
ctx.fillText(chartInfo.title, 8, 10);
this.drawVerticalGrid(ctx, height - bottomPadding, max, chartInfo);
ctx.restore();
}
private calcMax(chartInfo: ChartInfo): number {
if (chartInfo.max) {
return chartInfo.max;
}
const width = this.width;
const startTime = performance.now() - this.pollIntervalMs - width / this.pixelsPerMs;
let max = -Infinity;
for (const metricInfo of chartInfo.metrics) {
for (let i = this.metricsBuffer.length - 1; i >= 0; --i) {
const metrics = this.metricsBuffer[i];
const value = metrics.metrics.get(metricInfo.name);
if (value !== undefined) {
max = Math.max(max, value);
}
if (metrics.timestamp < startTime) {
break;
}
}
}
if (!this.metricsBuffer.length) {
return 10;
}
const base10 = Math.pow(10, Math.floor(Math.log10(max)));
max = Math.ceil(max / base10 / 2) * base10 * 2;
const alpha = 0.2;
chartInfo.currentMax = max * alpha + (chartInfo.currentMax || max) * (1 - alpha);
return chartInfo.currentMax;
}
private drawVerticalGrid(ctx: CanvasRenderingContext2D, height: number, max: number, info: ChartInfo): void {
let base = Math.pow(10, Math.floor(Math.log10(max)));
const firstDigit = Math.floor(max / base);
if (firstDigit !== 1 && firstDigit % 2 === 1) {
base *= 2;
}
let scaleValue = Math.floor(max / base) * base;
const span = max;
const topPadding = 18;
const visibleHeight = height - topPadding;
ctx.fillStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-inverted-opacity-50');
ctx.strokeStyle = this.gridColor;
ctx.beginPath();
for (let i = 0; i < 2; ++i) {
const y = calcY(scaleValue);
const labelText = formatNumber(scaleValue, info);
ctx.moveTo(0, y);
ctx.lineTo(4, y);
ctx.moveTo(ctx.measureText(labelText).width + 12, y);
ctx.lineTo(this.width, y);
ctx.fillText(labelText, 8, calcY(scaleValue) + 3);
scaleValue /= 2;
}
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, height + 0.5);
ctx.lineTo(this.width, height + 0.5);
ctx.strokeStyle = ThemeSupport.ThemeSupport.instance().getComputedValue('--color-background-inverted-opacity-2');
ctx.stroke();
function calcY(value: number): number {
return Math.round(height - visibleHeight * value / span) + 0.5;
}
}
private buildMetricPath(
chartInfo: ChartInfo, metricInfo: MetricInfo, height: number, scaleMax: number,
stackedChartBaseLandscape: Map<number, number>|null): Path2D {
const path = new Path2D();
const topPadding = 18;
const visibleHeight = height - topPadding;
if (visibleHeight < 1) {
return path;
}
const span = scaleMax;
const metricName = metricInfo.name;
const pixelsPerMs = this.pixelsPerMs;
const startTime = performance.now() - this.pollIntervalMs - this.width / pixelsPerMs;
const smooth = chartInfo.smooth;
let x = 0;
let lastY = 0;
let lastX = 0;
if (this.metricsBuffer.length) {
x = (this.metricsBuffer[0].timestamp - startTime) * pixelsPerMs;
path.moveTo(x, calcY(0));
path.lineTo(this.width + 5, calcY(0));
lastY = calcY(
(this.metricsBuffer[this.metricsBuffer.length - 1] as {
metrics: Map<string, number>,
}).metrics.get(metricName) ||
0);
lastX = this.width + 5;
path.lineTo(lastX, lastY);
}
for (let i = this.metricsBuffer.length - 1; i >= 0; --i) {
const metrics = this.metricsBuffer[i];
const timestamp = metrics.timestamp;
let value: number = metrics.metrics.get(metricName) || 0;
if (stackedChartBaseLandscape) {
value += stackedChartBaseLandscape.get(timestamp) || 0;
value = Platform.NumberUtilities.clamp(value, 0, 1);
stackedChartBaseLandscape.set(timestamp, value);
}
const y = calcY(value);
x = (timestamp - startTime) * pixelsPerMs;
if (smooth) {
const midX = (lastX + x) / 2;
path.bezierCurveTo(midX, lastY, midX, y, x, y);
} else {
path.lineTo(x, lastY);
path.lineTo(x, y);
}
lastX = x;
lastY = y;
if (timestamp < startTime) {
break;
}
}
return path;
function calcY(value: number): number {
return Math.round(height - visibleHeight * value / span) + 0.5;
}
}
override onResize(): void {
super.onResize();
this.recalcChartHeight();
}
private recalcChartHeight(): void {
let height = this.scaleHeight;
for (const chartInfo of this.chartInfos) {
if (this.activeCharts.has(chartInfo.metrics[0].name)) {
height += this.graphHeight;
}
}
this.height = Math.ceil(height * window.devicePixelRatio);
this.requestUpdate();
}
private createChartInfos(): ChartInfo[] {
const themeSupport = ThemeSupport.ThemeSupport.instance();
const elementForStyles = this.contentElement;
const defaults = {
color: undefined,
format: undefined,
currentMax: undefined,
max: undefined,
smooth: undefined,
stacked: undefined,
};
return [
{
...defaults,
title: i18nString(UIStrings.cpuUsage),
metrics: [
{
name: 'TaskDuration',
color: themeSupport.getComputedValue('--override-color-perf-monitor-cpu-task-duration', elementForStyles),
},
{
name: 'ScriptDuration',
color: themeSupport.getComputedValue('--override-color-perf-monitor-cpu-script-duration', elementForStyles),
},
{
name: 'LayoutDuration',
color: themeSupport.getComputedValue('--override-color-perf-monitor-cpu-layout-duration', elementForStyles),
},
{
name: 'RecalcStyleDuration',
color: themeSupport.getComputedValue(
'--override-color-perf-monitor-cpu-recalc-style-duration', elementForStyles),
},
],
format: Format.PERCENT,
smooth: true,
stacked: true,
color: themeSupport.getComputedValue('--override-color-perf-monitor-cpu', elementForStyles),
max: 1,
currentMax: undefined,
},
{
...defaults,
title: i18nString(UIStrings.jsHeapSize),
metrics: [
{
name: 'JSHeapTotalSize',
color: themeSupport.getComputedValue('--override-color-perf-monitor-jsheap-total-size', elementForStyles),
},
{
name: 'JSHeapUsedSize',
color: themeSupport.getComputedValue('--override-color-perf-monitor-jsheap-used-size', elementForStyles),
},
],
format: Format.BYTES,
color: themeSupport.getComputedValue('--override-color-perf-monitor-jsheap', elementForStyles),
},
{
...defaults,
title: i18nString(UIStrings.domNodes),
metrics: [
{
name: 'Nodes',
color: themeSupport.getComputedValue('--override-color-perf-monitor-dom-nodes', elementForStyles),
},
],
},
{
...defaults,
title: i18nString(UIStrings.jsEventListeners),
metrics: [
{
name: 'JSEventListeners',
color: themeSupport.getComputedValue('--override-color-perf-monitor-js-event-listeners', elementForStyles),
},
],
},
{
...defaults,
title: i18nString(UIStrings.documents),
metrics: [{
name: 'Documents',
color: themeSupport.getComputedValue('--override-color-perf-monitor-documents', elementForStyles),
}],
},
{
...defaults,
title: i18nString(UIStrings.documentFrames),
metrics: [{
name: 'Frames',
color: themeSupport.getComputedValue('--override-color-perf-monitor-document-frames', elementForStyles),
}],
},
{
...defaults,
title: i18nString(UIStrings.layoutsSec),
metrics: [{
name: 'LayoutCount',
color: themeSupport.getComputedValue('--override-color-perf-monitor-layout-count', elementForStyles),
}],
},
{
...defaults,
title: i18nString(UIStrings.styleRecalcsSec),
metrics: [
{
name: 'RecalcStyleCount',
color: themeSupport.getComputedValue('--override-color-perf-monitor-recalc-style-count', elementForStyles),
},
],
},
];
}
}
export const enum Format {
PERCENT = 'Percent',
BYTES = 'Bytes',
}
interface ControlPaneInput {
chartsInfo: ChartInfo[];
enabledCharts: Set<string>;
metricValues: Map<string, number>;
onCheckboxChange: (chartName: string, e: Event) => void;
}
type ControlPaneView = (input: ControlPaneInput, output: object, target: HTMLElement) => void;
const CONTROL_PANE_DEFAULT_VIEW: ControlPaneView = (input, _output, target) => {
render(
input.chartsInfo.map(chartInfo => {
const chartName = chartInfo.metrics[0].name;
const active = input.enabledCharts.has(chartName);
const value = input.metricValues.get(chartName) || 0;
return renderMetricIndicator(
chartInfo,
active,
value,
(e: Event) => input.onCheckboxChange(chartName, e),
);
}),
target);
};
export class ControlPane extends UI.Widget.VBox {
readonly #enabledChartsSetting: Common.Settings.Setting<string[]>;
readonly #enabledCharts: Set<string>;
#onMetricChanged: ((metricName: string, active: boolean) => void)|null = null;
#chartsInfo: ChartInfo[] = [];
readonly #metricValues = new Map<string, number>();
readonly #view: ControlPaneView;
constructor(element: HTMLElement, view = CONTROL_PANE_DEFAULT_VIEW) {
super(element, {useShadowDom: false, classes: ['perfmon-control-pane']});
this.#view = view;
this.#enabledChartsSetting = Common.Settings.Settings.instance().createSetting(
'perfmon-active-indicators2', ['TaskDuration', 'JSHeapTotalSize', 'Nodes']);
this.#enabledCharts = new Set(this.#enabledChartsSetting.get());
}
set chartsInfo(chartsInfo: ChartInfo[]) {
this.#chartsInfo = chartsInfo;
this.requestUpdate();
}
set onMetricChanged(callback: (metricName: string, active: boolean) => void) {
this.#onMetricChanged = callback;
for (const chartName of this.#enabledCharts) {
callback(chartName, true);
}
}
override performUpdate(): void {
const input = {
chartsInfo: this.#chartsInfo,
enabledCharts: this.#enabledCharts,
metricValues: this.#metricValues,
onCheckboxChange: this.#onCheckboxChange.bind(this),
};
this.#view(input, {}, this.element);
}
#onCheckboxChange(chartName: string, e: Event): void {
this.#onToggle(chartName, (e.target as HTMLInputElement).checked);
this.requestUpdate();
}
#onToggle(chartName: string, active: boolean): void {
if (active) {
this.#enabledCharts.add(chartName);
} else {
this.#enabledCharts.delete(chartName);
}
this.#enabledChartsSetting.set(Array.from(this.#enabledCharts));
if (this.#onMetricChanged) {
this.#onMetricChanged(chartName, active);
}
}
set metrics(metrics: Map<string, number>|undefined) {
if (!metrics) {
return;
}
for (const [name, value] of metrics.entries()) {
this.#metricValues.set(name, value);
}
this.requestUpdate();
}
}
let numberFormatter: Intl.NumberFormat;
let percentFormatter: Intl.NumberFormat;
export function formatNumber(value: number, info: ChartInfo): string {
if (!numberFormatter) {
numberFormatter = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1});
percentFormatter = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1, style: 'percent'});
}
switch (info.format) {
case Format.PERCENT:
return percentFormatter.format(value);
case Format.BYTES:
return i18n.ByteUtilities.bytesToString(value);
default:
return numberFormatter.format(value);
}
}
function renderMetricIndicator(
info: ChartInfo, active: boolean, value: number, onCheckboxChange: (e: Event) => void): TemplateResult {
const color = info.color || info.metrics[0].color;
const chartName = info.metrics[0].name;
// clang-format off
return html`
<div class="perfmon-indicator ${active ? 'active' : ''}" jslog=${VisualLogging.toggle().track({
click: true,
keydown: 'Enter',
}).context(Platform.StringUtilities.toKebabCase(chartName))}>
<devtools-checkbox .checked=${active}
=${onCheckboxChange} .jslogContext=${chartName}>${info.title}</devtools-checkbox>
<div class="perfmon-indicator-value" style="color:${color}">${
formatNumber(value, info)}</div>
</div>
`;
// clang-format on
}
export const format = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1});
export interface MetricInfo {
name: string;
color: string;
}
export interface ChartInfo {
title: Common.UIString.LocalizedString;
metrics: Array<{name: string, color: string}>;
max?: number;
currentMax?: number;
format?: Format;
smooth?: boolean;
color?: string;
stacked?: boolean;
}