diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /devtools/client/performance/modules/widgets | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
5 files changed, 1432 insertions, 0 deletions
diff --git a/devtools/client/performance/modules/widgets/graphs.js b/devtools/client/performance/modules/widgets/graphs.js new file mode 100644 index 0000000000..926fb43cf6 --- /dev/null +++ b/devtools/client/performance/modules/widgets/graphs.js @@ -0,0 +1,527 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the base line graph that all Performance line graphs use. + */ + +const { extend } = require("devtools/shared/extend"); +const LineGraphWidget = require("devtools/client/shared/widgets/LineGraphWidget"); +const MountainGraphWidget = require("devtools/client/shared/widgets/MountainGraphWidget"); +const { CanvasGraphUtils } = require("devtools/client/shared/widgets/Graphs"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkersOverview, +} = require("devtools/client/performance/modules/widgets/markers-overview"); +const { + createTierGraphDataFromFrameNode, +} = require("devtools/client/performance/modules/logic/jit"); + +/** + * For line graphs + */ +const HEIGHT = 35; // px +const STROKE_WIDTH = 1; // px +const DAMPEN_VALUES = 0.95; +const CLIPHEAD_LINE_COLOR = "#666"; +const SELECTION_LINE_COLOR = "#555"; +const SELECTION_BACKGROUND_COLOR_NAME = "graphs-blue"; +const FRAMERATE_GRAPH_COLOR_NAME = "graphs-green"; +const MEMORY_GRAPH_COLOR_NAME = "graphs-blue"; + +/** + * For timeline overview + */ +const MARKERS_GRAPH_HEADER_HEIGHT = 14; // px +const MARKERS_GRAPH_ROW_HEIGHT = 10; // px +const MARKERS_GROUP_VERTICAL_PADDING = 4; // px + +/** + * For optimization graph + */ +const OPTIMIZATIONS_GRAPH_RESOLUTION = 100; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function PerformanceGraph(parent, metric) { + LineGraphWidget.call(this, parent, { metric }); + this.setTheme(); +} + +PerformanceGraph.prototype = extend(LineGraphWidget.prototype, { + strokeWidth: STROKE_WIDTH, + dampenValuesFactor: DAMPEN_VALUES, + fixedHeight: HEIGHT, + clipheadLineColor: CLIPHEAD_LINE_COLOR, + selectionLineColor: SELECTION_LINE_COLOR, + withTooltipArrows: false, + withFixedTooltipPositions: true, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData([]); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + const mainColor = getColor(this.mainColor || "graphs-blue", theme); + this.backgroundColor = getColor("body-background", theme); + this.strokeColor = mainColor; + this.backgroundGradientStart = colorUtils.setAlpha(mainColor, 0.2); + this.backgroundGradientEnd = colorUtils.setAlpha(mainColor, 0.2); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor(SELECTION_BACKGROUND_COLOR_NAME, theme), + 0.25 + ); + this.selectionStripesColor = "rgba(255, 255, 255, 0.1)"; + this.maximumLineColor = colorUtils.setAlpha(mainColor, 0.4); + this.averageLineColor = colorUtils.setAlpha(mainColor, 0.7); + this.minimumLineColor = colorUtils.setAlpha(mainColor, 0.9); + }, +}); + +/** + * Constructor for the framerate graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function FramerateGraph(parent) { + PerformanceGraph.call(this, parent, ProfilerGlobal.L10N.getStr("graphs.fps")); +} + +FramerateGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: FRAMERATE_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, ticks }, resolution) { + this.dataDuration = duration; + return this.setDataFromTimestamps(ticks, resolution, duration); + }, +}); + +/** + * Constructor for the memory graph. Inherits from PerformanceGraph. + * + * @param Node parent + * The parent node holding the overview. + */ +function MemoryGraph(parent) { + PerformanceGraph.call( + this, + parent, + ProfilerGlobal.L10N.getStr("graphs.memory") + ); +} + +MemoryGraph.prototype = extend(PerformanceGraph.prototype, { + mainColor: MEMORY_GRAPH_COLOR_NAME, + setPerformanceData: function({ duration, memory }) { + this.dataDuration = duration; + return this.setData(memory); + }, +}); + +function TimelineGraph(parent, filter) { + MarkersOverview.call(this, parent, filter); +} + +TimelineGraph.prototype = extend(MarkersOverview.prototype, { + headerHeight: MARKERS_GRAPH_HEADER_HEIGHT, + rowHeight: MARKERS_GRAPH_ROW_HEIGHT, + groupPadding: MARKERS_GROUP_VERTICAL_PADDING, + setPerformanceData: MarkersOverview.prototype.setData, +}); + +/** + * Definitions file for GraphsController, indicating the constructor, + * selector and other meta for each of the graphs controller by + * GraphsController. + */ +const GRAPH_DEFINITIONS = { + memory: { + constructor: MemoryGraph, + selector: "#memory-overview", + }, + framerate: { + constructor: FramerateGraph, + selector: "#time-framerate", + }, + timeline: { + constructor: TimelineGraph, + selector: "#markers-overview", + primaryLink: true, + }, +}; + +/** + * A controller for orchestrating the performance's tool overview graphs. Constructs, + * syncs, toggles displays and defines the memory, framerate and timeline view. + * + * @param {object} definition + * @param {DOMElement} root + * @param {function} getFilter + * @param {function} getTheme + */ +function GraphsController({ definition, root, getFilter, getTheme }) { + this._graphs = {}; + this._enabled = new Set(); + this._definition = definition || GRAPH_DEFINITIONS; + this._root = root; + this._getFilter = getFilter; + this._getTheme = getTheme; + this._primaryLink = Object.keys(this._definition).filter( + name => this._definition[name].primaryLink + )[0]; + this.$ = root.ownerDocument.querySelector.bind(root.ownerDocument); + + EventEmitter.decorate(this); + this._onSelecting = this._onSelecting.bind(this); +} + +GraphsController.prototype = { + /** + * Returns the corresponding graph by `graphName`. + */ + get: function(graphName) { + return this._graphs[graphName]; + }, + + /** + * Iterates through all graphs and renders the data + * from a RecordingModel. Takes a resolution value used in + * some graphs. + * Saves rendering progress as a promise to be consumed by `destroy`, + * to wait for cleaning up rendering during destruction. + */ + async render(recordingData, resolution) { + // Get the previous render promise so we don't start rendering + // until the previous render cycle completes, which can occur + // especially when a recording is finished, and triggers a + // fresh rendering at a higher rate + await this._rendering; + + // Check after yielding to ensure we're not tearing down, + // as this can create a race condition in tests + if (this._destroyed) { + return; + } + + this._rendering = (async () => { + for (const graph of await this._getEnabled()) { + await graph.setPerformanceData(recordingData, resolution); + this.emit("rendered", graph.graphName); + } + })(); + await this._rendering; + }, + + /** + * Destroys the underlying graphs. + */ + async destroy() { + const primary = this._getPrimaryLink(); + + this._destroyed = true; + + if (primary) { + primary.off("selecting", this._onSelecting); + } + + // If there was rendering, wait until the most recent render cycle + // has finished + if (this._rendering) { + await this._rendering; + } + + for (const graph of this.getWidgets()) { + await graph.destroy(); + } + }, + + /** + * Applies the theme to the underlying graphs. Optionally takes + * a `redraw` boolean in the options to force redraw. + */ + setTheme: function(options = {}) { + const theme = options.theme || this._getTheme(); + for (const graph of this.getWidgets()) { + graph.setTheme(theme); + graph.refresh({ force: options.redraw }); + } + }, + + /** + * Sets up the graph, if needed. Returns a promise resolving + * to the graph if it is enabled once it's ready, or otherwise returns + * null if disabled. + */ + async isAvailable(graphName) { + if (!this._enabled.has(graphName)) { + return null; + } + + let graph = this.get(graphName); + + if (!graph) { + graph = await this._construct(graphName); + } + + await graph.ready(); + return graph; + }, + + /** + * Enable or disable a subgraph controlled by GraphsController. + * This determines what graphs are visible and get rendered. + */ + enable: function(graphName, isEnabled) { + const el = this.$(this._definition[graphName].selector); + el.classList[isEnabled ? "remove" : "add"]("hidden"); + + // If no status change, just return + if (this._enabled.has(graphName) === isEnabled) { + return; + } + if (isEnabled) { + this._enabled.add(graphName); + } else { + this._enabled.delete(graphName); + } + + // Invalidate our cache of ready-to-go graphs + this._enabledGraphs = null; + }, + + /** + * Disables all graphs controller by the GraphsController, and + * also hides the root element. This is a one way switch, and used + * when older platforms do not have any timeline data. + */ + disableAll: function() { + this._root.classList.add("hidden"); + // Hide all the subelements + Object.keys(this._definition).forEach(graphName => + this.enable(graphName, false) + ); + }, + + /** + * Sets a mapped selection on the graph that is the main controller + * for keeping the graphs' selections in sync. + */ + setMappedSelection: function(selection, { mapStart, mapEnd }) { + return this._getPrimaryLink().setMappedSelection(selection, { + mapStart, + mapEnd, + }); + }, + + /** + * Fetches the currently mapped selection. If graphs are not yet rendered, + * (which throws in Graphs.js), return null. + */ + getMappedSelection: function({ mapStart, mapEnd }) { + const primary = this._getPrimaryLink(); + if (primary && primary.hasData()) { + return primary.getMappedSelection({ mapStart, mapEnd }); + } + return null; + }, + + /** + * Returns an array of graphs that have been created, not necessarily + * enabled currently. + */ + getWidgets: function() { + return Object.keys(this._graphs).map(name => this._graphs[name]); + }, + + /** + * Drops the selection. + */ + dropSelection: function() { + if (this._getPrimaryLink()) { + return this._getPrimaryLink().dropSelection(); + } + return null; + }, + + /** + * Makes sure the selection is enabled or disabled in all the graphs. + */ + async selectionEnabled(enabled) { + for (const graph of await this._getEnabled()) { + graph.selectionEnabled = enabled; + } + }, + + /** + * Creates the graph `graphName` and initializes it. + */ + async _construct(graphName) { + const def = this._definition[graphName]; + const el = this.$(def.selector); + const filter = this._getFilter(); + const graph = (this._graphs[graphName] = new def.constructor(el, filter)); + graph.graphName = graphName; + + await graph.ready(); + + // Sync the graphs' animations and selections together + if (def.primaryLink) { + graph.on("selecting", this._onSelecting); + } else { + CanvasGraphUtils.linkAnimation(this._getPrimaryLink(), graph); + CanvasGraphUtils.linkSelection(this._getPrimaryLink(), graph); + } + + // Sets the container element's visibility based off of enabled status + el.classList[this._enabled.has(graphName) ? "remove" : "add"]("hidden"); + + this.setTheme(); + return graph; + }, + + /** + * Returns the main graph for this collection, that all graphs + * are bound to for syncing and selection. + */ + _getPrimaryLink: function() { + return this.get(this._primaryLink); + }, + + /** + * Emitted when a selection occurs. + */ + _onSelecting: function() { + this.emit("selecting"); + }, + + /** + * Resolves to an array with all graphs that are enabled, and + * creates them if needed. Different than just iterating over `this._graphs`, + * as those could be enabled. Uses caching, as rendering happens many times per second, + * compared to how often which graphs/features are changed (rarely). + */ + async _getEnabled() { + if (this._enabledGraphs) { + return this._enabledGraphs; + } + const enabled = []; + for (const graphName of this._enabled) { + const graph = await this.isAvailable(graphName); + if (graph) { + enabled.push(graph); + } + } + this._enabledGraphs = enabled; + return this._enabledGraphs; + }, +}; + +/** + * A base class for performance graphs to inherit from. + * + * @param Node parent + * The parent node holding the overview. + * @param string metric + * The unit of measurement for this graph. + */ +function OptimizationsGraph(parent) { + MountainGraphWidget.call(this, parent); + this.setTheme(); +} + +OptimizationsGraph.prototype = extend(MountainGraphWidget.prototype, { + async render(threadNode, frameNode) { + // Regardless if we draw or clear the graph, wait + // until it's ready. + await this.ready(); + + if (!threadNode || !frameNode) { + this.setData([]); + return; + } + + const { sampleTimes } = threadNode; + + if (!sampleTimes.length) { + this.setData([]); + return; + } + + // Take startTime/endTime from samples recorded, rather than + // using duration directly from threadNode, as the first sample that + // equals the startTime does not get recorded. + const startTime = sampleTimes[0]; + const endTime = sampleTimes[sampleTimes.length - 1]; + + const bucketSize = (endTime - startTime) / OPTIMIZATIONS_GRAPH_RESOLUTION; + const data = createTierGraphDataFromFrameNode( + frameNode, + sampleTimes, + bucketSize + ); + + // If for some reason we don't have data (like the frameNode doesn't + // have optimizations, but it shouldn't be at this point if it doesn't), + // log an error. + if (!data) { + console.error( + `FrameNode#${frameNode.location} does not have optimizations data to render.` + ); + return; + } + + this.dataOffsetX = startTime; + await this.setData(data); + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + theme = theme || "light"; + + const interpreterColor = getColor("graphs-red", theme); + const baselineColor = getColor("graphs-blue", theme); + const ionColor = getColor("graphs-green", theme); + + this.format = [ + { color: interpreterColor }, + { color: baselineColor }, + { color: ionColor }, + ]; + + this.backgroundColor = getColor("sidebar-background", theme); + }, +}); + +exports.OptimizationsGraph = OptimizationsGraph; +exports.FramerateGraph = FramerateGraph; +exports.MemoryGraph = MemoryGraph; +exports.TimelineGraph = TimelineGraph; +exports.GraphsController = GraphsController; diff --git a/devtools/client/performance/modules/widgets/marker-details.js b/devtools/client/performance/modules/widgets/marker-details.js new file mode 100644 index 0000000000..2a8ce17ce4 --- /dev/null +++ b/devtools/client/performance/modules/widgets/marker-details.js @@ -0,0 +1,177 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the rendering code for the marker sidebar. + */ + +const EventEmitter = require("devtools/shared/event-emitter"); +const { + MarkerDOMUtils, +} = require("devtools/client/performance/modules/marker-dom-utils"); + +/** + * A detailed view for one single marker. + * + * @param Node parent + * The parent node holding the view. + * @param Node splitter + * The splitter node that the resize event is bound to. + */ +function MarkerDetails(parent, splitter) { + EventEmitter.decorate(this); + + this._document = parent.ownerDocument; + this._parent = parent; + this._splitter = splitter; + + this._onClick = this._onClick.bind(this); + this._onSplitterMouseUp = this._onSplitterMouseUp.bind(this); + + this._parent.addEventListener("click", this._onClick); + this._splitter.addEventListener("mouseup", this._onSplitterMouseUp); + + this.hidden = true; +} + +MarkerDetails.prototype = { + /** + * Sets this view's width. + * @param number + */ + set width(value) { + this._parent.setAttribute("width", value); + }, + + /** + * Sets this view's width. + * @return number + */ + get width() { + return +this._parent.getAttribute("width"); + }, + + /** + * Sets this view's visibility. + * @param boolean + */ + set hidden(value) { + if (this._parent.hidden != value) { + this._parent.hidden = value; + this.emit("resize"); + } + }, + + /** + * Gets this view's visibility. + * @param boolean + */ + get hidden() { + return this._parent.hidden; + }, + + /** + * Clears the marker details from this view. + */ + empty: function() { + this._parent.innerHTML = ""; + }, + + /** + * Populates view with marker's details. + * + * @param object params + * An options object holding: + * - marker: The marker to display. + * - frames: Array of stack frame information; see stack.js. + * - allocations: Whether or not allocations were enabled for this + * recording. [optional] + */ + render: function(options) { + const { marker, frames } = options; + this.empty(); + + const elements = []; + elements.push(MarkerDOMUtils.buildTitle(this._document, marker)); + elements.push(MarkerDOMUtils.buildDuration(this._document, marker)); + MarkerDOMUtils.buildFields(this._document, marker).forEach(f => + elements.push(f) + ); + MarkerDOMUtils.buildCustom(this._document, marker, options).forEach(f => + elements.push(f) + ); + + // Build a stack element -- and use the "startStack" label if + // we have both a startStack and endStack. + if (marker.stack) { + const type = marker.endStack ? "startStack" : "stack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.stack, + frames, + type, + }) + ); + } + if (marker.endStack) { + const type = "endStack"; + elements.push( + MarkerDOMUtils.buildStackTrace(this._document, { + frameIndex: marker.endStack, + frames, + type, + }) + ); + } + + elements.forEach(el => this._parent.appendChild(el)); + }, + + /** + * Handles click in the marker details view. Based on the target, + * can handle different actions -- only supporting view source links + * for the moment. + */ + _onClick: function(e) { + const data = findActionFromEvent(e.target, this._parent); + if (!data) { + return; + } + + this.emit(data.action, data); + }, + + /** + * Handles the "mouseup" event on the marker details view splitter. + */ + _onSplitterMouseUp: function() { + this.emit("resize"); + }, +}; + +/** + * Take an element from an event `target`, and ascend through + * the DOM, looking for an element with a `data-action` attribute. Return + * the parsed `data-action` value found, or null if none found before + * reaching the parent `container`. + * + * @param {Element} target + * @param {Element} container + * @return {?object} + */ +function findActionFromEvent(target, container) { + let el = target; + let action; + while (el !== container) { + action = el.getAttribute("data-action"); + if (action) { + return JSON.parse(action); + } + el = el.parentNode; + } + return null; +} + +exports.MarkerDetails = MarkerDetails; diff --git a/devtools/client/performance/modules/widgets/markers-overview.js b/devtools/client/performance/modules/widgets/markers-overview.js new file mode 100644 index 0000000000..ea762a371d --- /dev/null +++ b/devtools/client/performance/modules/widgets/markers-overview.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the "markers overview" graph, which is a minimap of all + * the timeline data. Regions inside it may be selected, determining which + * markers are visible in the "waterfall". + */ + +const { extend } = require("devtools/shared/extend"); +const { + AbstractCanvasGraph, +} = require("devtools/client/shared/widgets/Graphs"); + +const { colorUtils } = require("devtools/shared/css/color"); +const { getColor } = require("devtools/client/shared/theme"); +const ProfilerGlobal = require("devtools/client/performance/modules/global"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); + +const OVERVIEW_HEADER_HEIGHT = 14; // px +const OVERVIEW_ROW_HEIGHT = 11; // px + +const OVERVIEW_SELECTION_LINE_COLOR = "#666"; +const OVERVIEW_CLIPHEAD_LINE_COLOR = "#555"; + +const OVERVIEW_HEADER_TICKS_MULTIPLE = 100; // ms +const OVERVIEW_HEADER_TICKS_SPACING_MIN = 75; // px +const OVERVIEW_HEADER_TEXT_FONT_SIZE = 9; // px +const OVERVIEW_HEADER_TEXT_FONT_FAMILY = "sans-serif"; +const OVERVIEW_HEADER_TEXT_PADDING_LEFT = 6; // px +const OVERVIEW_HEADER_TEXT_PADDING_TOP = 1; // px +const OVERVIEW_MARKER_WIDTH_MIN = 4; // px +const OVERVIEW_GROUP_VERTICAL_PADDING = 5; // px + +/** + * An overview for the markers data. + * + * @param Node parent + * The parent node holding the overview. + * @param Array<String> filter + * List of names of marker types that should not be shown. + */ +function MarkersOverview(parent, filter = [], ...args) { + AbstractCanvasGraph.apply(this, [parent, "markers-overview", ...args]); + this.setTheme(); + this.setFilter(filter); +} + +MarkersOverview.prototype = extend(AbstractCanvasGraph.prototype, { + clipheadLineColor: OVERVIEW_CLIPHEAD_LINE_COLOR, + selectionLineColor: OVERVIEW_SELECTION_LINE_COLOR, + headerHeight: OVERVIEW_HEADER_HEIGHT, + rowHeight: OVERVIEW_ROW_HEIGHT, + groupPadding: OVERVIEW_GROUP_VERTICAL_PADDING, + + /** + * Compute the height of the overview. + */ + get fixedHeight() { + return this.headerHeight + this.rowHeight * this._numberOfGroups; + }, + + /** + * List of marker types that should not be shown in the graph. + */ + setFilter: function(filter) { + this._paintBatches = new Map(); + this._filter = filter; + this._groupMap = Object.create(null); + + const observedGroups = new Set(); + + for (const type in TIMELINE_BLUEPRINT) { + if (filter.includes(type)) { + continue; + } + this._paintBatches.set(type, { + definition: TIMELINE_BLUEPRINT[type], + batch: [], + }); + observedGroups.add(TIMELINE_BLUEPRINT[type].group); + } + + // Take our set of observed groups and order them and map + // the group numbers to fill in the holes via `_groupMap`. + // This normalizes our rows by removing rows that aren't used + // if filters are enabled. + let actualPosition = 0; + for (const groupNumber of Array.from(observedGroups).sort()) { + this._groupMap[groupNumber] = actualPosition++; + } + this._numberOfGroups = Object.keys(this._groupMap).length; + }, + + /** + * Disables selection and empties this graph. + */ + clearView: function() { + this.selectionEnabled = false; + this.dropSelection(); + this.setData({ duration: 0, markers: [] }); + }, + + /** + * Renders the graph's data source. + * @see AbstractCanvasGraph.prototype.buildGraphImage + */ + buildGraphImage: function() { + const { markers, duration } = this._data; + + const { canvas, ctx } = this._getNamedCanvas("markers-overview-data"); + const canvasWidth = this._width; + const canvasHeight = this._height; + + // Group markers into separate paint batches. This is necessary to + // draw all markers sharing the same style at once. + for (const marker of markers) { + // Again skip over markers that we're filtering -- we don't want them + // to be labeled as "Unknown" + if (!MarkerBlueprintUtils.shouldDisplayMarker(marker, this._filter)) { + continue; + } + + const markerType = + this._paintBatches.get(marker.name) || + this._paintBatches.get("UNKNOWN"); + markerType.batch.push(marker); + } + + // Calculate each row's height, and the time-based scaling. + + const groupHeight = this.rowHeight * this._pixelRatio; + const groupPadding = this.groupPadding * this._pixelRatio; + const headerHeight = this.headerHeight * this._pixelRatio; + const dataScale = (this.dataScaleX = canvasWidth / duration); + + // Draw the header and overview background. + + ctx.fillStyle = this.headerBackgroundColor; + ctx.fillRect(0, 0, canvasWidth, headerHeight); + + ctx.fillStyle = this.backgroundColor; + ctx.fillRect(0, headerHeight, canvasWidth, canvasHeight); + + // Draw the alternating odd/even group backgrounds. + + ctx.fillStyle = this.alternatingBackgroundColor; + ctx.beginPath(); + + for (let i = 0; i < this._numberOfGroups; i += 2) { + const top = headerHeight + i * groupHeight; + ctx.rect(0, top, canvasWidth, groupHeight); + } + + ctx.fill(); + + // Draw the timeline header ticks. + + const fontSize = OVERVIEW_HEADER_TEXT_FONT_SIZE * this._pixelRatio; + const fontFamily = OVERVIEW_HEADER_TEXT_FONT_FAMILY; + const textPaddingLeft = + OVERVIEW_HEADER_TEXT_PADDING_LEFT * this._pixelRatio; + const textPaddingTop = OVERVIEW_HEADER_TEXT_PADDING_TOP * this._pixelRatio; + + const tickInterval = TickUtils.findOptimalTickInterval({ + ticksMultiple: OVERVIEW_HEADER_TICKS_MULTIPLE, + ticksSpacingMin: OVERVIEW_HEADER_TICKS_SPACING_MIN, + dataScale: dataScale, + }); + + ctx.textBaseline = "middle"; + ctx.font = fontSize + "px " + fontFamily; + ctx.fillStyle = this.headerTextColor; + ctx.strokeStyle = this.headerTimelineStrokeColor; + ctx.beginPath(); + + for (let x = 0; x < canvasWidth; x += tickInterval) { + const lineLeft = x; + const textLeft = lineLeft + textPaddingLeft; + const time = Math.round(x / dataScale); + const label = ProfilerGlobal.L10N.getFormatStr("timeline.tick", time); + ctx.fillText(label, textLeft, headerHeight / 2 + textPaddingTop); + ctx.moveTo(lineLeft, 0); + ctx.lineTo(lineLeft, canvasHeight); + } + + ctx.stroke(); + + // Draw the timeline markers. + + for (const [, { definition, batch }] of this._paintBatches) { + const group = this._groupMap[definition.group]; + const top = headerHeight + group * groupHeight + groupPadding / 2; + const height = groupHeight - groupPadding; + + const color = getColor(definition.colorName, this.theme); + ctx.fillStyle = color; + ctx.beginPath(); + + for (const { start, end } of batch) { + const left = start * dataScale; + const width = Math.max( + (end - start) * dataScale, + OVERVIEW_MARKER_WIDTH_MIN + ); + ctx.rect(left, top, width, height); + } + + ctx.fill(); + + // Since all the markers in this batch (thus sharing the same style) have + // been drawn, empty it. The next time new markers will be available, + // they will be sorted and drawn again. + batch.length = 0; + } + + return canvas; + }, + + /** + * Sets the theme via `theme` to either "light" or "dark", + * and updates the internal styling to match. Requires a redraw + * to see the effects. + */ + setTheme: function(theme) { + this.theme = theme = theme || "light"; + this.backgroundColor = getColor("body-background", theme); + this.selectionBackgroundColor = colorUtils.setAlpha( + getColor("selection-background", theme), + 0.25 + ); + this.selectionStripesColor = colorUtils.setAlpha("#fff", 0.1); + this.headerBackgroundColor = getColor("body-background", theme); + this.headerTextColor = getColor("body-color", theme); + this.headerTimelineStrokeColor = colorUtils.setAlpha( + getColor("text-color-alt", theme), + 0.25 + ); + this.alternatingBackgroundColor = colorUtils.setAlpha( + getColor("body-color", theme), + 0.05 + ); + }, +}); + +exports.MarkersOverview = MarkersOverview; diff --git a/devtools/client/performance/modules/widgets/moz.build b/devtools/client/performance/modules/widgets/moz.build new file mode 100644 index 0000000000..d04890425c --- /dev/null +++ b/devtools/client/performance/modules/widgets/moz.build @@ -0,0 +1,11 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "graphs.js", + "marker-details.js", + "markers-overview.js", + "tree-view.js", +) diff --git a/devtools/client/performance/modules/widgets/tree-view.js b/devtools/client/performance/modules/widgets/tree-view.js new file mode 100644 index 0000000000..79ad8229ff --- /dev/null +++ b/devtools/client/performance/modules/widgets/tree-view.js @@ -0,0 +1,461 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This file contains the tree view, displaying all the samples and frames + * received from the proviler in a tree-like structure. + */ + +const { L10N } = require("devtools/client/performance/modules/global"); +const { extend } = require("devtools/shared/extend"); +const { + AbstractTreeItem, +} = require("resource://devtools/client/shared/widgets/AbstractTreeItem.jsm"); + +const URL_LABEL_TOOLTIP = L10N.getStr("table.url.tooltiptext"); +const VIEW_OPTIMIZATIONS_TOOLTIP = L10N.getStr( + "table.view-optimizations.tooltiptext2" +); + +const CALL_TREE_INDENTATION = 16; // px + +// Used for rendering values in cells +const FORMATTERS = { + TIME: value => + L10N.getFormatStr("table.ms2", L10N.numberWithDecimals(value, 2)), + PERCENT: value => + L10N.getFormatStr("table.percentage3", L10N.numberWithDecimals(value, 2)), + NUMBER: value => value || 0, + BYTESIZE: value => L10N.getFormatStr("table.bytes", value || 0), +}; + +/** + * Definitions for rendering cells. Triads of class name, property name from + * `frame.getInfo()`, and a formatter function. + */ +const CELLS = { + duration: ["duration", "totalDuration", FORMATTERS.TIME], + percentage: ["percentage", "totalPercentage", FORMATTERS.PERCENT], + selfDuration: ["self-duration", "selfDuration", FORMATTERS.TIME], + selfPercentage: ["self-percentage", "selfPercentage", FORMATTERS.PERCENT], + samples: ["samples", "samples", FORMATTERS.NUMBER], + + selfSize: ["self-size", "selfSize", FORMATTERS.BYTESIZE], + selfSizePercentage: [ + "self-size-percentage", + "selfSizePercentage", + FORMATTERS.PERCENT, + ], + selfCount: ["self-count", "selfCount", FORMATTERS.NUMBER], + selfCountPercentage: [ + "self-count-percentage", + "selfCountPercentage", + FORMATTERS.PERCENT, + ], + size: ["size", "totalSize", FORMATTERS.BYTESIZE], + sizePercentage: [ + "size-percentage", + "totalSizePercentage", + FORMATTERS.PERCENT, + ], + count: ["count", "totalCount", FORMATTERS.NUMBER], + countPercentage: [ + "count-percentage", + "totalCountPercentage", + FORMATTERS.PERCENT, + ], +}; +const CELL_TYPES = Object.keys(CELLS); + +const DEFAULT_SORTING_PREDICATE = (frameA, frameB) => { + const dataA = frameA.getDisplayedData(); + const dataB = frameB.getDisplayedData(); + const isAllocations = "totalSize" in dataA; + + if (isAllocations) { + if (this.inverted && dataA.selfSize !== dataB.selfSize) { + return dataA.selfSize < dataB.selfSize ? 1 : -1; + } + return dataA.totalSize < dataB.totalSize ? 1 : -1; + } + + if (this.inverted && dataA.selfPercentage !== dataB.selfPercentage) { + return dataA.selfPercentage < dataB.selfPercentage ? 1 : -1; + } + return dataA.totalPercentage < dataB.totalPercentage ? 1 : -1; +}; + +// depth +const DEFAULT_AUTO_EXPAND_DEPTH = 3; +const DEFAULT_VISIBLE_CELLS = { + duration: true, + percentage: true, + selfDuration: true, + selfPercentage: true, + samples: true, + function: true, + + // allocation columns + count: false, + selfCount: false, + size: false, + selfSize: false, + countPercentage: false, + selfCountPercentage: false, + sizePercentage: false, + selfSizePercentage: false, +}; + +/** + * An item in a call tree view, which looks like this: + * + * Time (ms) | Cost | Calls | Function + * ============================================================================ + * 1,000.00 | 100.00% | | ▼ (root) + * 500.12 | 50.01% | 300 | ▼ foo Categ. 1 + * 300.34 | 30.03% | 1500 | ▼ bar Categ. 2 + * 10.56 | 0.01% | 42 | ▶ call_with_children Categ. 3 + * 90.78 | 0.09% | 25 | call_without_children Categ. 4 + * + * Every instance of a `CallView` represents a row in the call tree. The same + * parent node is used for all rows. + * + * @param CallView caller + * The CallView considered the "caller" frame. This newly created + * instance will be represent the "callee". Should be null for root nodes. + * @param ThreadNode | FrameNode frame + * Details about this function, like { samples, duration, calls } etc. + * @param number level [optional] + * The indentation level in the call tree. The root node is at level 0. + * @param boolean hidden [optional] + * Whether this node should be hidden and not contribute to depth/level + * calculations. Defaults to false. + * @param boolean inverted [optional] + * Whether the call tree has been inverted (bottom up, rather than + * top-down). Defaults to false. + * @param function sortingPredicate [optional] + * The predicate used to sort the tree items when created. Defaults to + * the caller's `sortingPredicate` if a caller exists, otherwise defaults + * to DEFAULT_SORTING_PREDICATE. The two passed arguments are FrameNodes. + * @param number autoExpandDepth [optional] + * The depth to which the tree should automatically expand. Defualts to + * the caller's `autoExpandDepth` if a caller exists, otherwise defaults + * to DEFAULT_AUTO_EXPAND_DEPTH. + * @param object visibleCells + * An object specifying which cells are visible in the tree. Defaults to + * the caller's `visibleCells` if a caller exists, otherwise defaults + * to DEFAULT_VISIBLE_CELLS. + * @param boolean showOptimizationHint [optional] + * Whether or not to show an icon indicating if the frame has optimization + * data. + */ +function CallView({ + caller, + frame, + level, + hidden, + inverted, + sortingPredicate, + autoExpandDepth, + visibleCells, + showOptimizationHint, +}) { + AbstractTreeItem.call(this, { + parent: caller, + level: level | (0 - (hidden ? 1 : 0)), + }); + + if (sortingPredicate != null) { + this.sortingPredicate = sortingPredicate; + } else if (caller) { + this.sortingPredicate = caller.sortingPredicate; + } else { + this.sortingPredicate = DEFAULT_SORTING_PREDICATE; + } + + if (autoExpandDepth != null) { + this.autoExpandDepth = autoExpandDepth; + } else if (caller) { + this.autoExpandDepth = caller.autoExpandDepth; + } else { + this.autoExpandDepth = DEFAULT_AUTO_EXPAND_DEPTH; + } + + if (visibleCells != null) { + this.visibleCells = visibleCells; + } else if (caller) { + this.visibleCells = caller.visibleCells; + } else { + this.visibleCells = Object.create(DEFAULT_VISIBLE_CELLS); + } + + this.caller = caller; + this.frame = frame; + this.hidden = hidden; + this.inverted = inverted; + this.showOptimizationHint = showOptimizationHint; + + this._onUrlClick = this._onUrlClick.bind(this); +} + +CallView.prototype = extend(AbstractTreeItem.prototype, { + /** + * Creates the view for this tree node. + * @param Node document + * @param Node arrowNode + * @return Node + */ + _displaySelf: function(document, arrowNode) { + const frameInfo = this.getDisplayedData(); + const cells = []; + + for (const type of CELL_TYPES) { + if (this.visibleCells[type]) { + // Inline for speed, but pass in the formatted value via + // cell definition, as well as the element type. + cells.push( + this._createCell( + document, + CELLS[type][2](frameInfo[CELLS[type][1]]), + CELLS[type][0] + ) + ); + } + } + + if (this.visibleCells.function) { + cells.push( + this._createFunctionCell( + document, + arrowNode, + frameInfo.name, + frameInfo, + this.level + ) + ); + } + + const targetNode = document.createXULElement("hbox"); + targetNode.className = "call-tree-item"; + targetNode.setAttribute( + "origin", + frameInfo.isContent ? "content" : "chrome" + ); + targetNode.setAttribute("category", frameInfo.categoryData.abbrev || ""); + targetNode.setAttribute("tooltiptext", frameInfo.tooltiptext); + + if (this.hidden) { + targetNode.style.display = "none"; + } + + for (let i = 0; i < cells.length; i++) { + targetNode.appendChild(cells[i]); + } + + return targetNode; + }, + + /** + * Populates this node in the call tree with the corresponding "callees". + * These are defined in the `frame` data source for this call view. + * @param array:AbstractTreeItem children + */ + _populateSelf: function(children) { + const newLevel = this.level + 1; + + for (const newFrame of this.frame.calls) { + children.push( + new CallView({ + caller: this, + frame: newFrame, + level: newLevel, + inverted: this.inverted, + }) + ); + } + + // Sort the "callees" asc. by samples, before inserting them in the tree, + // if no other sorting predicate was specified on this on the root item. + children.sort(this.sortingPredicate.bind(this)); + }, + + /** + * Functions creating each cell in this call view. + * Invoked by `_displaySelf`. + */ + _createCell: function(doc, value, type) { + const cell = doc.createXULElement("description"); + cell.className = "plain call-tree-cell"; + cell.setAttribute("type", type); + cell.setAttribute("crop", "end"); + // Add a tabulation to the cell text in case it's is selected and copied. + cell.textContent = value + "\t"; + return cell; + }, + + _createFunctionCell: function( + doc, + arrowNode, + frameName, + frameInfo, + frameLevel + ) { + const cell = doc.createXULElement("hbox"); + cell.className = "call-tree-cell"; + cell.style.marginInlineStart = frameLevel * CALL_TREE_INDENTATION + "px"; + cell.setAttribute("type", "function"); + cell.appendChild(arrowNode); + + // Render optimization hint if this frame has opt data. + if ( + this.root.showOptimizationHint && + frameInfo.hasOptimizations && + !frameInfo.isMetaCategory + ) { + const icon = doc.createXULElement("description"); + icon.setAttribute("tooltiptext", VIEW_OPTIMIZATIONS_TOOLTIP); + icon.className = "opt-icon"; + cell.appendChild(icon); + } + + // Don't render a name label node if there's no function name. A different + // location label node will be rendered instead. + if (frameName) { + const nameNode = doc.createXULElement("description"); + nameNode.className = "plain call-tree-name"; + nameNode.textContent = frameName; + cell.appendChild(nameNode); + } + + // Don't render detailed labels for meta category frames + if (!frameInfo.isMetaCategory) { + this._appendFunctionDetailsCells(doc, cell, frameInfo); + } + + // Don't render an expando-arrow for leaf nodes. + const hasDescendants = Object.keys(this.frame.calls).length > 0; + if (!hasDescendants) { + arrowNode.setAttribute("invisible", ""); + } + + // Add a line break to the last description of the row in case it's selected + // and copied. + const lastDescription = cell.querySelector("description:last-of-type"); + lastDescription.textContent = lastDescription.textContent + "\n"; + + // Add spaces as frameLevel indicators in case the row is selected and + // copied. These spaces won't be displayed in the cell content. + const firstDescription = cell.querySelector("description:first-of-type"); + const levelIndicator = frameLevel > 0 ? " ".repeat(frameLevel) : ""; + firstDescription.textContent = + levelIndicator + firstDescription.textContent; + + return cell; + }, + + _appendFunctionDetailsCells: function(doc, cell, frameInfo) { + if (frameInfo.fileName) { + const urlNode = doc.createXULElement("description"); + urlNode.className = "plain call-tree-url"; + urlNode.textContent = frameInfo.fileName; + urlNode.setAttribute( + "tooltiptext", + URL_LABEL_TOOLTIP + " → " + frameInfo.url + ); + urlNode.addEventListener("mousedown", this._onUrlClick); + cell.appendChild(urlNode); + } + + if (frameInfo.line) { + const lineNode = doc.createXULElement("description"); + lineNode.className = "plain call-tree-line"; + lineNode.textContent = ":" + frameInfo.line; + cell.appendChild(lineNode); + } + + if (frameInfo.column) { + const columnNode = doc.createXULElement("description"); + columnNode.className = "plain call-tree-column"; + columnNode.textContent = ":" + frameInfo.column; + cell.appendChild(columnNode); + } + + if (frameInfo.host) { + const hostNode = doc.createXULElement("description"); + hostNode.className = "plain call-tree-host"; + hostNode.textContent = frameInfo.host; + cell.appendChild(hostNode); + } + + if (frameInfo.categoryData.label) { + const categoryNode = doc.createXULElement("description"); + categoryNode.className = "plain call-tree-category"; + categoryNode.style.color = frameInfo.categoryData.color; + categoryNode.textContent = frameInfo.categoryData.label; + cell.appendChild(categoryNode); + } + }, + + /** + * Gets the data displayed about this tree item, based on the FrameNode + * model associated with this view. + * + * @return object + */ + getDisplayedData: function() { + if (this._cachedDisplayedData) { + return this._cachedDisplayedData; + } + + this._cachedDisplayedData = this.frame.getInfo({ + root: this.root.frame, + allocations: this.visibleCells.count || this.visibleCells.selfCount, + }); + + return this._cachedDisplayedData; + + /** + * When inverting call tree, the costs and times are dependent on position + * in the tree. We must only count leaf nodes with self cost, and total costs + * dependent on how many times the leaf node was found with a full stack path. + * + * Total | Self | Calls | Function + * ============================================================================ + * 100% | 100% | 100 | ▼ C + * 50% | 0% | 50 | ▼ B + * 50% | 0% | 50 | ▼ A + * 50% | 0% | 50 | ▼ B + * + * Every instance of a `CallView` represents a row in the call tree. The same + * container node is used for all rows. + */ + }, + + /** + * Toggles the category information hidden or visible. + * @param boolean visible + */ + toggleCategories: function(visible) { + if (!visible) { + this.container.setAttribute("categories-hidden", ""); + } else { + this.container.removeAttribute("categories-hidden"); + } + }, + + /** + * Handler for the "click" event on the url node of this call view. + */ + _onUrlClick: function(e) { + e.preventDefault(); + e.stopPropagation(); + // Only emit for left click events + if (e.button === 0) { + this.root.emit("link", this); + } + }, +}); + +exports.CallView = CallView; |