| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @implements {SDK.SDKModelObserver} |
| * @unrestricted |
| */ |
| export class PerformanceMonitorImpl extends UI.HBox { |
| constructor() { |
| super(true); |
| this.registerRequiredCSS('performance_monitor/performanceMonitor.css'); |
| this.contentElement.classList.add('perfmon-pane'); |
| /** @type {!Array<!{timestamp: number, metrics: !Map<string, number>}>} */ |
| this._metricsBuffer = []; |
| /** @const */ |
| this._pixelsPerMs = 10 / 1000; |
| /** @const */ |
| this._pollIntervalMs = 500; |
| /** @const */ |
| this._scaleHeight = 16; |
| /** @const */ |
| this._graphHeight = 90; |
| this._gridColor = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.08)', UI.ThemeSupport.ColorUsage.Foreground); |
| this._controlPane = new PerformanceMonitor.PerformanceMonitor.ControlPane(this.contentElement); |
| const chartContainer = this.contentElement.createChild('div', 'perfmon-chart-container'); |
| this._canvas = /** @type {!HTMLCanvasElement} */ (chartContainer.createChild('canvas')); |
| this._canvas.tabIndex = -1; |
| UI.ARIAUtils.setAccessibleName( |
| this._canvas, Common.UIString('Graphs displaying a real-time view of performance metrics')); |
| this.contentElement.createChild('div', 'perfmon-chart-suspend-overlay fill').createChild('div').textContent = |
| Common.UIString('Paused'); |
| this._controlPane.addEventListener( |
| PerformanceMonitor.PerformanceMonitor.ControlPane.Events.MetricChanged, this._recalcChartHeight, this); |
| SDK.targetManager.observeModels(SDK.PerformanceMetricsModel, this); |
| } |
| |
| /** |
| * @override |
| */ |
| wasShown() { |
| if (!this._model) { |
| return; |
| } |
| SDK.targetManager.addEventListener(SDK.TargetManager.Events.SuspendStateChanged, this._suspendStateChanged, this); |
| this._model.enable(); |
| this._suspendStateChanged(); |
| } |
| |
| /** |
| * @override |
| */ |
| willHide() { |
| if (!this._model) { |
| return; |
| } |
| SDK.targetManager.removeEventListener( |
| SDK.TargetManager.Events.SuspendStateChanged, this._suspendStateChanged, this); |
| this._stopPolling(); |
| this._model.disable(); |
| } |
| |
| /** |
| * @override |
| * @param {!SDK.PerformanceMetricsModel} model |
| */ |
| modelAdded(model) { |
| if (this._model) { |
| return; |
| } |
| this._model = model; |
| if (this.isShowing()) { |
| this.wasShown(); |
| } |
| } |
| |
| /** |
| * @override |
| * @param {!SDK.PerformanceMetricsModel} model |
| */ |
| modelRemoved(model) { |
| if (this._model !== model) { |
| return; |
| } |
| if (this.isShowing()) { |
| this.willHide(); |
| } |
| this._model = null; |
| } |
| |
| _suspendStateChanged() { |
| const suspended = SDK.targetManager.allTargetsSuspended(); |
| if (suspended) { |
| this._stopPolling(); |
| } else { |
| this._startPolling(); |
| } |
| this.contentElement.classList.toggle('suspended', suspended); |
| } |
| |
| _startPolling() { |
| this._startTimestamp = 0; |
| this._pollTimer = setInterval(() => this._poll(), this._pollIntervalMs); |
| this.onResize(); |
| animate.call(this); |
| |
| /** |
| * @this {PerformanceMonitor.PerformanceMonitor} |
| */ |
| function animate() { |
| this._draw(); |
| this._animationId = this.contentElement.window().requestAnimationFrame(animate.bind(this)); |
| } |
| } |
| |
| _stopPolling() { |
| clearInterval(this._pollTimer); |
| this.contentElement.window().cancelAnimationFrame(this._animationId); |
| this._metricsBuffer = []; |
| } |
| |
| async _poll() { |
| const data = await this._model.requestMetrics(); |
| const timestamp = data.timestamp; |
| const metrics = data.metrics; |
| this._metricsBuffer.push({timestamp, metrics: 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._controlPane.updateMetrics(metrics); |
| } |
| |
| _draw() { |
| const ctx = /** @type {!CanvasRenderingContext2D} */ (this._canvas.getContext('2d')); |
| 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._controlPane.charts()) { |
| if (!this._controlPane.isActive(chartInfo.metrics[0].name)) { |
| continue; |
| } |
| this._drawChart(ctx, chartInfo, this._graphHeight); |
| ctx.translate(0, this._graphHeight); |
| } |
| ctx.restore(); |
| this._drawHorizontalGrid(ctx); |
| ctx.restore(); |
| } |
| |
| /** |
| * @param {!CanvasRenderingContext2D} ctx |
| */ |
| _drawHorizontalGrid(ctx) { |
| const labelDistanceSeconds = 10; |
| const lightGray = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.02)', UI.ThemeSupport.ColorUsage.Foreground); |
| ctx.font = '10px ' + Host.fontFamily(); |
| ctx.fillStyle = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.55)', UI.ThemeSupport.ColorUsage.Foreground); |
| 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(); |
| } |
| } |
| |
| /** |
| * @param {!CanvasRenderingContext2D} ctx |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo |
| * @param {number} height |
| */ |
| _drawChart(ctx, chartInfo, height) { |
| 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(UI.themeSupport.patchColorText('white', UI.ThemeSupport.ColorUsage.Background)); |
| for (const path of paths.reverse()) { |
| const color = path.color; |
| ctx.save(); |
| ctx.fillStyle = backgroundColor.blendWith(Common.Color.parse(color).setAlpha(0.2)).asString(null); |
| ctx.fill(path.path); |
| ctx.strokeStyle = color; |
| ctx.lineWidth = 0.5; |
| ctx.stroke(path.path); |
| ctx.restore(); |
| } |
| ctx.fillStyle = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.55)', UI.ThemeSupport.ColorUsage.Foreground); |
| ctx.font = `10px ${Host.fontFamily()}`; |
| ctx.fillText(chartInfo.title, 8, 10); |
| this._drawVerticalGrid(ctx, height - bottomPadding, max, chartInfo); |
| ctx.restore(); |
| } |
| |
| /** |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo |
| * @return {number} |
| */ |
| _calcMax(chartInfo) { |
| 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); |
| 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; |
| } |
| |
| /** |
| * @param {!CanvasRenderingContext2D} ctx |
| * @param {number} height |
| * @param {number} max |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info |
| */ |
| _drawVerticalGrid(ctx, height, max, info) { |
| 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 = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.55)', UI.ThemeSupport.ColorUsage.Foreground); |
| ctx.strokeStyle = this._gridColor; |
| ctx.beginPath(); |
| for (let i = 0; i < 2; ++i) { |
| const y = calcY(scaleValue); |
| const labelText = PerformanceMonitor.PerformanceMonitor.MetricIndicator._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 = UI.themeSupport.patchColorText('rgba(0, 0, 0, 0.2)', UI.ThemeSupport.ColorUsage.Foreground); |
| ctx.stroke(); |
| /** |
| * @param {number} value |
| * @return {number} |
| */ |
| function calcY(value) { |
| return Math.round(height - visibleHeight * value / span) + 0.5; |
| } |
| } |
| |
| /** |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} chartInfo |
| * @param {!PerformanceMonitor.PerformanceMonitor.MetricInfo} metricInfo |
| * @param {number} height |
| * @param {number} scaleMax |
| * @param {?Map<number, number>} stackedChartBaseLandscape |
| * @return {!Path2D} |
| */ |
| _buildMetricPath(chartInfo, metricInfo, height, scaleMax, stackedChartBaseLandscape) { |
| 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.peekLast().metrics.get(metricName)); |
| 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 = metrics.metrics.get(metricName); |
| if (stackedChartBaseLandscape) { |
| value += stackedChartBaseLandscape.get(timestamp) || 0; |
| value = Number.constrain(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; |
| |
| /** |
| * @param {number} value |
| * @return {number} |
| */ |
| function calcY(value) { |
| return Math.round(height - visibleHeight * value / span) + 0.5; |
| } |
| } |
| |
| /** |
| * @override |
| */ |
| onResize() { |
| super.onResize(); |
| this._width = this._canvas.offsetWidth; |
| this._canvas.width = Math.round(this._width * window.devicePixelRatio); |
| this._recalcChartHeight(); |
| } |
| |
| _recalcChartHeight() { |
| let height = this._scaleHeight; |
| for (const chartInfo of this._controlPane.charts()) { |
| if (this._controlPane.isActive(chartInfo.metrics[0].name)) { |
| height += this._graphHeight; |
| } |
| } |
| this._height = Math.ceil(height * window.devicePixelRatio); |
| this._canvas.height = this._height; |
| this._canvas.style.height = `${this._height / window.devicePixelRatio}px`; |
| } |
| } |
| |
| /** @enum {symbol} */ |
| export const Format = { |
| Percent: Symbol('Percent'), |
| Bytes: Symbol('Bytes'), |
| }; |
| |
| export class ControlPane extends Common.Object { |
| /** |
| * @param {!Element} parent |
| */ |
| constructor(parent) { |
| super(); |
| this.element = parent.createChild('div', 'perfmon-control-pane'); |
| |
| this._enabledChartsSetting = |
| Common.settings.createSetting('perfmonActiveIndicators2', ['TaskDuration', 'JSHeapTotalSize', 'Nodes']); |
| /** @type {!Set<string>} */ |
| this._enabledCharts = new Set(this._enabledChartsSetting.get()); |
| const format = PerformanceMonitor.PerformanceMonitor.Format; |
| |
| /** @type {!Array<!PerformanceMonitor.PerformanceMonitor.ChartInfo>} */ |
| this._chartsInfo = [ |
| { |
| title: Common.UIString('CPU usage'), |
| metrics: [ |
| {name: 'TaskDuration', color: '#999'}, {name: 'ScriptDuration', color: 'orange'}, |
| {name: 'LayoutDuration', color: 'blueviolet'}, {name: 'RecalcStyleDuration', color: 'violet'} |
| ], |
| format: format.Percent, |
| smooth: true, |
| stacked: true, |
| color: 'red', |
| max: 1 |
| }, |
| { |
| title: Common.UIString('JS heap size'), |
| metrics: [{name: 'JSHeapTotalSize', color: '#99f'}, {name: 'JSHeapUsedSize', color: 'blue'}], |
| format: format.Bytes, |
| color: 'blue' |
| }, |
| {title: Common.UIString('DOM Nodes'), metrics: [{name: 'Nodes', color: 'green'}]}, |
| {title: Common.UIString('JS event listeners'), metrics: [{name: 'JSEventListeners', color: 'yellowgreen'}]}, |
| {title: Common.UIString('Documents'), metrics: [{name: 'Documents', color: 'darkblue'}]}, |
| {title: Common.UIString('Document Frames'), metrics: [{name: 'Frames', color: 'darkcyan'}]}, |
| {title: Common.UIString('Layouts / sec'), metrics: [{name: 'LayoutCount', color: 'hotpink'}]}, |
| {title: Common.UIString('Style recalcs / sec'), metrics: [{name: 'RecalcStyleCount', color: 'deeppink'}]} |
| ]; |
| for (const info of this._chartsInfo) { |
| for (const metric of info.metrics) { |
| metric.color = UI.themeSupport.patchColorText(metric.color, UI.ThemeSupport.ColorUsage.Foreground); |
| } |
| } |
| |
| /** @type {!Map<string, !PerformanceMonitor.PerformanceMonitor.MetricIndicator>} */ |
| this._indicators = new Map(); |
| for (const chartInfo of this._chartsInfo) { |
| const chartName = chartInfo.metrics[0].name; |
| const active = this._enabledCharts.has(chartName); |
| const indicator = new PerformanceMonitor.PerformanceMonitor.MetricIndicator( |
| this.element, chartInfo, active, this._onToggle.bind(this, chartName)); |
| this._indicators.set(chartName, indicator); |
| } |
| } |
| |
| /** |
| * @param {string} chartName |
| * @param {boolean} active |
| */ |
| _onToggle(chartName, active) { |
| if (active) { |
| this._enabledCharts.add(chartName); |
| } else { |
| this._enabledCharts.delete(chartName); |
| } |
| this._enabledChartsSetting.set(Array.from(this._enabledCharts)); |
| this.dispatchEventToListeners(PerformanceMonitor.PerformanceMonitor.ControlPane.Events.MetricChanged); |
| } |
| |
| /** |
| * @return {!Array<!PerformanceMonitor.PerformanceMonitor.ChartInfo>} |
| */ |
| charts() { |
| return this._chartsInfo; |
| } |
| |
| /** |
| * @param {string} metricName |
| * @return {boolean} |
| */ |
| isActive(metricName) { |
| return this._enabledCharts.has(metricName); |
| } |
| |
| /** |
| * @param {!Map<string, number>} metrics |
| */ |
| updateMetrics(metrics) { |
| for (const name of this._indicators.keys()) { |
| if (metrics.has(name)) { |
| this._indicators.get(name).setValue(metrics.get(name)); |
| } |
| } |
| } |
| } |
| |
| /** @enum {symbol} */ |
| export const Events = { |
| MetricChanged: Symbol('MetricChanged') |
| }; |
| |
| export class MetricIndicator { |
| /** |
| * @param {!Element} parent |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info |
| * @param {boolean} active |
| * @param {function(boolean)} onToggle |
| */ |
| constructor(parent, info, active, onToggle) { |
| const color = info.color || info.metrics[0].color; |
| this._info = info; |
| this._active = active; |
| this._onToggle = onToggle; |
| this.element = parent.createChild('div', 'perfmon-indicator'); |
| this._swatchElement = UI.Icon.create('smallicon-checkmark-square', 'perfmon-indicator-swatch'); |
| this._swatchElement.style.backgroundColor = color; |
| this.element.appendChild(this._swatchElement); |
| this.element.createChild('div', 'perfmon-indicator-title').textContent = info.title; |
| this._valueElement = this.element.createChild('div', 'perfmon-indicator-value'); |
| this._valueElement.style.color = color; |
| this.element.addEventListener('click', () => this._toggleIndicator()); |
| this.element.addEventListener('keypress', event => this._handleKeypress(event)); |
| this.element.classList.toggle('active', active); |
| UI.ARIAUtils.markAsCheckbox(this.element); |
| UI.ARIAUtils.setChecked(this.element, this._active); |
| this.element.tabIndex = 0; |
| } |
| |
| /** |
| * @param {number} value |
| * @param {!PerformanceMonitor.PerformanceMonitor.ChartInfo} info |
| * @return {string} |
| */ |
| static _formatNumber(value, info) { |
| if (!PerformanceMonitor.PerformanceMonitor.MetricIndicator._numberFormatter) { |
| PerformanceMonitor.PerformanceMonitor.MetricIndicator._numberFormatter = |
| new Intl.NumberFormat('en-US', {maximumFractionDigits: 1}); |
| PerformanceMonitor.PerformanceMonitor.MetricIndicator._percentFormatter = |
| new Intl.NumberFormat('en-US', {maximumFractionDigits: 1, style: 'percent'}); |
| } |
| switch (info.format) { |
| case PerformanceMonitor.PerformanceMonitor.Format.Percent: |
| return PerformanceMonitor.PerformanceMonitor.MetricIndicator._percentFormatter.format(value); |
| case PerformanceMonitor.PerformanceMonitor.Format.Bytes: |
| return Number.bytesToString(value); |
| default: |
| return PerformanceMonitor.PerformanceMonitor.MetricIndicator._numberFormatter.format(value); |
| } |
| } |
| |
| /** |
| * @param {number} value |
| */ |
| setValue(value) { |
| this._valueElement.textContent = |
| PerformanceMonitor.PerformanceMonitor.MetricIndicator._formatNumber(value, this._info); |
| } |
| |
| _toggleIndicator() { |
| this._active = !this._active; |
| this.element.classList.toggle('active', this._active); |
| UI.ARIAUtils.setChecked(this.element, this._active); |
| this._onToggle(this._active); |
| } |
| |
| /** |
| * @param {!Event} event |
| */ |
| _handleKeypress(event) { |
| const keyboardEvent = /** @type {!KeyboardEvent} */ (event); |
| if (keyboardEvent.key === ' ' || keyboardEvent.key === 'Enter') { |
| this._toggleIndicator(); |
| } |
| } |
| } |
| |
| export const _format = new Intl.NumberFormat('en-US', {maximumFractionDigits: 1}); |
| |
| /* Legacy exported object */ |
| self.PerformanceMonitor = self.PerformanceMonitor || {}; |
| |
| /* Legacy exported object */ |
| PerformanceMonitor = PerformanceMonitor || {}; |
| |
| /** |
| * @constructor |
| */ |
| PerformanceMonitor.PerformanceMonitor = PerformanceMonitorImpl; |
| |
| /** |
| * @typedef {!{ |
| * name: string, |
| * color: string |
| * }} |
| */ |
| PerformanceMonitor.PerformanceMonitor.MetricInfo; |
| |
| PerformanceMonitor.PerformanceMonitor.Format = Format; |
| |
| /** |
| * @typedef {!{ |
| * title: string, |
| * metrics: !Array<!PerformanceMonitor.PerformanceMonitor.MetricInfo>, |
| * max: (number|undefined), |
| * currentMax: (number|undefined), |
| * format: (!Format|undefined), |
| * smooth: (boolean|undefined) |
| * }} |
| */ |
| PerformanceMonitor.PerformanceMonitor.ChartInfo; |
| |
| /** |
| * @constructor |
| */ |
| PerformanceMonitor.PerformanceMonitor.ControlPane = ControlPane; |
| |
| /** @enum {symbol} */ |
| PerformanceMonitor.PerformanceMonitor.ControlPane.Events = Events; |
| |
| /** |
| * @constructor |
| */ |
| PerformanceMonitor.PerformanceMonitor.MetricIndicator = MetricIndicator; |
| PerformanceMonitor.PerformanceMonitor.MetricIndicator._format = _format; |