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/views | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/performance/views')
-rw-r--r-- | devtools/client/performance/views/details-abstract-subview.js | 232 | ||||
-rw-r--r-- | devtools/client/performance/views/details-js-call-tree.js | 234 | ||||
-rw-r--r-- | devtools/client/performance/views/details-js-flamegraph.js | 143 | ||||
-rw-r--r-- | devtools/client/performance/views/details-memory-call-tree.js | 145 | ||||
-rw-r--r-- | devtools/client/performance/views/details-memory-flamegraph.js | 138 | ||||
-rw-r--r-- | devtools/client/performance/views/details-waterfall.js | 282 | ||||
-rw-r--r-- | devtools/client/performance/views/details.js | 295 | ||||
-rw-r--r-- | devtools/client/performance/views/moz.build | 17 | ||||
-rw-r--r-- | devtools/client/performance/views/overview.js | 457 | ||||
-rw-r--r-- | devtools/client/performance/views/recordings.js | 249 | ||||
-rw-r--r-- | devtools/client/performance/views/toolbar.js | 188 |
11 files changed, 2380 insertions, 0 deletions
diff --git a/devtools/client/performance/views/details-abstract-subview.js b/devtools/client/performance/views/details-abstract-subview.js new file mode 100644 index 0000000000..074d003229 --- /dev/null +++ b/devtools/client/performance/views/details-abstract-subview.js @@ -0,0 +1,232 @@ +/* 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/. */ +/* globals PerformanceController, OverviewView, DetailsView */ +"use strict"; + +const { + setNamedTimeout, + clearNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); +const EVENTS = require("devtools/client/performance/events"); + +/** + * A base class from which all detail views inherit. + */ +const DetailsSubview = { + /** + * Sets up the view with event binding. + */ + initialize: function() { + this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind( + this + ); + this._onOverviewRangeChange = this._onOverviewRangeChange.bind(this); + this._onDetailsViewSelected = this._onDetailsViewSelected.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged); + OverviewView.on( + EVENTS.UI_OVERVIEW_RANGE_SELECTED, + this._onOverviewRangeChange + ); + DetailsView.on( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + + const self = this; + const originalRenderFn = this.render; + const afterRenderFn = () => { + this._wasRendered = true; + }; + + this.render = async function(...args) { + const maybeRetval = await originalRenderFn.apply(self, args); + afterRenderFn(); + return maybeRetval; + }; + }, + + /** + * Unbinds events. + */ + destroy: function() { + clearNamedTimeout("range-change-debounce"); + + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged); + OverviewView.off( + EVENTS.UI_OVERVIEW_RANGE_SELECTED, + this._onOverviewRangeChange + ); + DetailsView.off( + EVENTS.UI_DETAILS_VIEW_SELECTED, + this._onDetailsViewSelected + ); + }, + + /** + * Returns true if this view was rendered at least once. + */ + get wasRenderedAtLeastOnce() { + return !!this._wasRendered; + }, + + /** + * Amount of time (in milliseconds) to wait until this view gets updated, + * when the range is changed in the overview. + */ + rangeChangeDebounceTime: 0, + + /** + * When the overview range changes, all details views will require a + * rerendering at a later point, determined by `shouldUpdateWhenShown` and + * `canUpdateWhileHidden` and whether or not its the current view. + * Set `requiresUpdateOnRangeChange` to false to not invalidate the view + * when the range changes. + */ + requiresUpdateOnRangeChange: true, + + /** + * Flag specifying if this view should be updated when selected. This will + * be set to true, for example, when the range changes in the overview and + * this view is not currently visible. + */ + shouldUpdateWhenShown: false, + + /** + * Flag specifying if this view may get updated even when it's not selected. + * Should only be used in tests. + */ + canUpdateWhileHidden: false, + + /** + * An array of preferences under `devtools.performance.ui.` that the view should + * rerender and callback `this._onRerenderPrefChanged` upon change. + */ + rerenderPrefs: [], + + /** + * An array of preferences under `devtools.performance.` that the view should + * observe and callback `this._onObservedPrefChange` upon change. + */ + observedPrefs: [], + + /** + * Flag specifying if this view should update while the overview selection + * area is actively being dragged by the mouse. + */ + shouldUpdateWhileMouseIsActive: false, + + /** + * Called when recording stops or is selected. + */ + _onRecordingStoppedOrSelected: function(state, recording) { + if (typeof state !== "string") { + recording = state; + } + if (arguments.length === 3 && state !== "recording-stopped") { + return; + } + + if (!recording || !recording.isCompleted()) { + return; + } + if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) { + this.render(OverviewView.getTimeInterval()); + } else { + this.shouldUpdateWhenShown = true; + } + }, + + /** + * Fired when a range is selected or cleared in the OverviewView. + */ + _onOverviewRangeChange: function(interval) { + if (!this.requiresUpdateOnRangeChange) { + return; + } + if (DetailsView.isViewSelected(this)) { + const debounced = () => { + if ( + !this.shouldUpdateWhileMouseIsActive && + OverviewView.isMouseActive + ) { + // Don't render yet, while the selection is still being dragged. + setNamedTimeout( + "range-change-debounce", + this.rangeChangeDebounceTime, + debounced + ); + } else { + this.render(interval); + } + }; + setNamedTimeout( + "range-change-debounce", + this.rangeChangeDebounceTime, + debounced + ); + } else { + this.shouldUpdateWhenShown = true; + } + }, + + /** + * Fired when a view is selected in the DetailsView. + */ + _onDetailsViewSelected: function() { + if (DetailsView.isViewSelected(this) && this.shouldUpdateWhenShown) { + this.render(OverviewView.getTimeInterval()); + this.shouldUpdateWhenShown = false; + } + }, + + /** + * Fired when a preference in `devtools.performance.ui.` is changed. + */ + _onPrefChanged: function(prefName, prefValue) { + if (~this.observedPrefs.indexOf(prefName) && this._onObservedPrefChange) { + this._onObservedPrefChange(prefName); + } + + // All detail views require a recording to be complete, so do not + // attempt to render if recording is in progress or does not exist. + const recording = PerformanceController.getCurrentRecording(); + if (!recording || !recording.isCompleted()) { + return; + } + + if (!~this.rerenderPrefs.indexOf(prefName)) { + return; + } + + if (this._onRerenderPrefChanged) { + this._onRerenderPrefChanged(prefName); + } + + if (DetailsView.isViewSelected(this) || this.canUpdateWhileHidden) { + this.render(OverviewView.getTimeInterval()); + } else { + this.shouldUpdateWhenShown = true; + } + }, +}; + +exports.DetailsSubview = DetailsSubview; diff --git a/devtools/client/performance/views/details-js-call-tree.js b/devtools/client/performance/views/details-js-call-tree.js new file mode 100644 index 0000000000..7ef2d05483 --- /dev/null +++ b/devtools/client/performance/views/details-js-call-tree.js @@ -0,0 +1,234 @@ +/* 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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + +const EVENTS = require("devtools/client/performance/events"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const JITOptimizationsView = React.createFactory( + require("devtools/client/performance/components/JITOptimizations") +); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * CallTree view containing profiler call tree, controlled by DetailsView. + */ +const JsCallTreeView = extend(DetailsSubview, { + rerenderPrefs: [ + "invert-call-tree", + "show-platform-data", + "flatten-tree-recursion", + "show-jit-optimizations", + ], + + // Units are in milliseconds. + rangeChangeDebounceTime: 75, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._onLink = this._onLink.bind(this); + this._onFocus = this._onFocus.bind(this); + + this.container = $("#js-calltree-view .call-tree-cells-container"); + + this.optimizationsElement = $("#jit-optimizations-view"); + }, + + /** + * Unbinds events. + */ + destroy: function() { + ReactDOM.unmountComponentAtNode(this.optimizationsElement); + this.optimizationsElement = null; + this.container = null; + this.threadNode = null; + DetailsSubview.destroy.call(this); + }, + + /** + * Method for handling all the set up for rendering a new call tree. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const profile = recording.getProfile(); + const showOptimizations = PerformanceController.getOption( + "show-jit-optimizations" + ); + + const options = { + contentOnly: !PerformanceController.getOption("show-platform-data"), + invertTree: PerformanceController.getOption("invert-call-tree"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + showOptimizationHint: showOptimizations, + }; + const threadNode = (this.threadNode = this._prepareCallTree( + profile, + interval, + options + )); + this._populateCallTree(threadNode, options); + + // For better or worse, re-rendering loses frame selection, + // so we should always hide opts on rerender + this.hideOptimizations(); + + this.emit(EVENTS.UI_JS_CALL_TREE_RENDERED); + }, + + showOptimizations: function() { + this.optimizationsElement.classList.remove("hidden"); + }, + + hideOptimizations: function() { + this.optimizationsElement.classList.add("hidden"); + }, + + _onFocus: function(treeItem) { + const showOptimizations = PerformanceController.getOption( + "show-jit-optimizations" + ); + const frameNode = treeItem.frame; + const optimizationSites = + frameNode && frameNode.hasOptimizations() + ? frameNode.getOptimizations().optimizationSites + : []; + + if (!showOptimizations || !frameNode || optimizationSites.length === 0) { + this.hideOptimizations(); + this.emit("focus", treeItem); + return; + } + + this.showOptimizations(); + + const frameData = frameNode.getInfo(); + const optimizations = JITOptimizationsView({ + frameData, + optimizationSites, + onViewSourceInDebugger: ({ url, line, column }) => { + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + }); + + ReactDOM.render(optimizations, this.optimizationsElement); + + this.emit("focus", treeItem); + }, + + /** + * Fired on the "link" event for the call tree in this container. + */ + _onLink: function(treeItem) { + const { url, line, column } = treeItem.frame.getInfo(); + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the call tree. + */ + _prepareCallTree: function(profile, { startTime, endTime }, options) { + const thread = profile.threads[0]; + const { contentOnly, invertTree, flattenRecursion } = options; + const threadNode = new ThreadNode(thread, { + startTime, + endTime, + contentOnly, + invertTree, + flattenRecursion, + }); + + // Real profiles from nsProfiler (i.e. not synthesized from allocation + // logs) always have a (root) node. Go down one level in the uninverted + // view to avoid displaying both the synthesized root node and the (root) + // node from the profiler. + if (!invertTree) { + threadNode.calls = threadNode.calls[0].calls; + } + + return threadNode; + }, + + /** + * Renders the call tree. + */ + _populateCallTree: function(frameNode, options = {}) { + // If we have an empty profile (no samples), then don't invert the tree, as + // it would hide the root node and a completely blank call tree space can be + // mis-interpreted as an error. + const inverted = options.invertTree && frameNode.samples > 0; + + const root = new CallView({ + frame: frameNode, + inverted: inverted, + // The synthesized root node is hidden in inverted call trees. + hidden: inverted, + // Call trees should only auto-expand when not inverted. Passing undefined + // will default to the CALL_TREE_AUTO_EXPAND depth. + autoExpandDepth: inverted ? 0 : undefined, + showOptimizationHint: options.showOptimizationHint, + }); + + // Bind events. + root.on("link", this._onLink); + root.on("focus", this._onFocus); + + // Clear out other call trees. + this.container.innerHTML = ""; + root.attachTo(this.container); + + // When platform data isn't shown, hide the cateogry labels, since they're + // only available for C++ frames. Pass *false* to make them invisible. + root.toggleCategories(!options.contentOnly); + + // Return the CallView for tests + return root; + }, + + toString: () => "[object JsCallTreeView]", +}); + +EventEmitter.decorate(JsCallTreeView); + +exports.JsCallTreeView = JsCallTreeView; diff --git a/devtools/client/performance/views/details-js-flamegraph.js b/devtools/client/performance/views/details-js-flamegraph.js new file mode 100644 index 0000000000..bacdb018f5 --- /dev/null +++ b/devtools/client/performance/views/details-js-flamegraph.js @@ -0,0 +1,143 @@ +/* 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/. */ +/* globals $, PerformanceController, OverviewView */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const EVENTS = require("devtools/client/performance/events"); +const { L10N } = require("devtools/client/performance/modules/global"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const { + FlameGraph, + FlameGraphUtils, +} = require("devtools/client/shared/widgets/FlameGraph"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * FlameGraph view containing a pyramid-like visualization of a profile, + * controlled by DetailsView. + */ +const JsFlameGraphView = extend(DetailsSubview, { + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-platform-data", + "show-idle-blocks", + ], + + /** + * Sets up the view with event binding. + */ + async initialize() { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#js-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + await this.graph.ready(); + + this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.on("selecting", this._onRangeChangeInGraph); + }, + + /** + * Unbinds events. + */ + async destroy() { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + await this.graph.destroy(); + }, + + /** + * Method for handling all the set up for rendering a new flamegraph. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const duration = recording.getDuration(); + const profile = recording.getProfile(); + const thread = profile.threads[0]; + + const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, { + invertTree: PerformanceController.getOption("invert-flame-graph"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + contentOnly: !PerformanceController.getOption("show-platform-data"), + showIdleBlocks: + PerformanceController.getOption("show-idle-blocks") && + L10N.getStr("table.idle"), + }); + + this.graph.setData({ + data, + bounds: { + startTime: 0, + endTime: duration, + }, + visible: { + startTime: interval.startTime || 0, + endTime: interval.endTime || duration, + }, + }); + + this.graph.focus(); + + this.emit(EVENTS.UI_JS_FLAMEGRAPH_RENDERED); + }, + + /** + * Fired when a range is selected or cleared in the FlameGraph. + */ + _onRangeChangeInGraph: function() { + const interval = this.graph.getViewRange(); + + // Squelch rerendering this view when we update the range here + // to avoid recursion, as our FlameGraph handles rerendering itself + // when originating from within the graph. + this.requiresUpdateOnRangeChange = false; + OverviewView.setTimeInterval(interval); + this.requiresUpdateOnRangeChange = true; + }, + + /** + * Called whenever a pref is changed and this view needs to be rerendered. + */ + _onRerenderPrefChanged: function() { + const recording = PerformanceController.getCurrentRecording(); + const profile = recording.getProfile(); + const thread = profile.threads[0]; + FlameGraphUtils.removeFromCache(thread); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graph.setTheme(theme); + this.graph.refresh({ force: true }); + }, + + toString: () => "[object JsFlameGraphView]", +}); + +EventEmitter.decorate(JsFlameGraphView); + +exports.JsFlameGraphView = JsFlameGraphView; diff --git a/devtools/client/performance/views/details-memory-call-tree.js b/devtools/client/performance/views/details-memory-call-tree.js new file mode 100644 index 0000000000..17fcfcad0c --- /dev/null +++ b/devtools/client/performance/views/details-memory-call-tree.js @@ -0,0 +1,145 @@ +/* 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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); + +const EVENTS = require("devtools/client/performance/events"); +const { + ThreadNode, +} = require("devtools/client/performance/modules/logic/tree-model"); +const { + CallView, +} = require("devtools/client/performance/modules/widgets/tree-view"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * CallTree view containing memory allocation sites, controlled by DetailsView. + */ +const MemoryCallTreeView = extend(DetailsSubview, { + rerenderPrefs: ["invert-call-tree"], + + // Units are in milliseconds. + rangeChangeDebounceTime: 100, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._onLink = this._onLink.bind(this); + + this.container = $("#memory-calltree-view > .call-tree-cells-container"); + }, + + /** + * Unbinds events. + */ + destroy: function() { + DetailsSubview.destroy.call(this); + }, + + /** + * Method for handling all the set up for rendering a new call tree. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const options = { + invertTree: PerformanceController.getOption("invert-call-tree"), + }; + const recording = PerformanceController.getCurrentRecording(); + const allocations = recording.getAllocations(); + const threadNode = this._prepareCallTree(allocations, interval, options); + this._populateCallTree(threadNode, options); + this.emit(EVENTS.UI_MEMORY_CALL_TREE_RENDERED); + }, + + /** + * Fired on the "link" event for the call tree in this container. + */ + _onLink: function(treeItem) { + const { url, line, column } = treeItem.frame.getInfo(); + PerformanceController.viewSourceInDebugger(url, line, column).then( + success => { + if (success) { + this.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); + } else { + this.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); + } + } + ); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the call tree. + */ + _prepareCallTree: function(allocations, { startTime, endTime }, options) { + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + const { invertTree } = options; + + return new ThreadNode(thread, { startTime, endTime, invertTree }); + }, + + /** + * Renders the call tree. + */ + _populateCallTree: function(frameNode, options = {}) { + // If we have an empty profile (no samples), then don't invert the tree, as + // it would hide the root node and a completely blank call tree space can be + // mis-interpreted as an error. + const inverted = options.invertTree && frameNode.samples > 0; + + const root = new CallView({ + frame: frameNode, + inverted: inverted, + // Root nodes are hidden in inverted call trees. + hidden: inverted, + // Call trees should only auto-expand when not inverted. Passing undefined + // will default to the CALL_TREE_AUTO_EXPAND depth. + autoExpandDepth: inverted ? 0 : undefined, + // Some cells like the time duration and cost percentage don't make sense + // for a memory allocations call tree. + visibleCells: { + selfCount: true, + count: true, + selfSize: true, + size: true, + selfCountPercentage: true, + countPercentage: true, + selfSizePercentage: true, + sizePercentage: true, + function: true, + }, + }); + + // Bind events. + root.on("link", this._onLink); + + // Pipe "focus" events to the view, mostly for tests + root.on("focus", () => this.emit("focus")); + + // Clear out other call trees. + this.container.innerHTML = ""; + root.attachTo(this.container); + + // Memory allocation samples don't contain cateogry labels. + root.toggleCategories(false); + }, + + toString: () => "[object MemoryCallTreeView]", +}); + +EventEmitter.decorate(MemoryCallTreeView); + +exports.MemoryCallTreeView = MemoryCallTreeView; diff --git a/devtools/client/performance/views/details-memory-flamegraph.js b/devtools/client/performance/views/details-memory-flamegraph.js new file mode 100644 index 0000000000..e8f1f51fd1 --- /dev/null +++ b/devtools/client/performance/views/details-memory-flamegraph.js @@ -0,0 +1,138 @@ +/* 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/. */ +/* globals $, PerformanceController, OverviewView */ +"use strict"; + +const { + FlameGraph, + FlameGraphUtils, +} = require("devtools/client/shared/widgets/FlameGraph"); +const { extend } = require("devtools/shared/extend"); +const RecordingUtils = require("devtools/shared/performance/recording-utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const EVENTS = require("devtools/client/performance/events"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); +const { L10N } = require("devtools/client/performance/modules/global"); + +/** + * FlameGraph view containing a pyramid-like visualization of memory allocation + * sites, controlled by DetailsView. + */ +const MemoryFlameGraphView = extend(DetailsSubview, { + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-idle-blocks", + ], + + /** + * Sets up the view with event binding. + */ + async initialize() { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#memory-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + await this.graph.ready(); + + this._onRangeChangeInGraph = this._onRangeChangeInGraph.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.on("selecting", this._onRangeChangeInGraph); + }, + + /** + * Unbinds events. + */ + async destroy() { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + await this.graph.destroy(); + }, + + /** + * Method for handling all the set up for rendering a new flamegraph. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + const duration = recording.getDuration(); + const allocations = recording.getAllocations(); + + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + const data = FlameGraphUtils.createFlameGraphDataFromThread(thread, { + invertStack: PerformanceController.getOption("invert-flame-graph"), + flattenRecursion: PerformanceController.getOption( + "flatten-tree-recursion" + ), + showIdleBlocks: + PerformanceController.getOption("show-idle-blocks") && + L10N.getStr("table.idle"), + }); + + this.graph.setData({ + data, + bounds: { + startTime: 0, + endTime: duration, + }, + visible: { + startTime: interval.startTime || 0, + endTime: interval.endTime || duration, + }, + }); + + this.emit(EVENTS.UI_MEMORY_FLAMEGRAPH_RENDERED); + }, + + /** + * Fired when a range is selected or cleared in the FlameGraph. + */ + _onRangeChangeInGraph: function() { + const interval = this.graph.getViewRange(); + + // Squelch rerendering this view when we update the range here + // to avoid recursion, as our FlameGraph handles rerendering itself + // when originating from within the graph. + this.requiresUpdateOnRangeChange = false; + OverviewView.setTimeInterval(interval); + this.requiresUpdateOnRangeChange = true; + }, + + /** + * Called whenever a pref is changed and this view needs to be rerendered. + */ + _onRerenderPrefChanged: function() { + const recording = PerformanceController.getCurrentRecording(); + const allocations = recording.getAllocations(); + const thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + FlameGraphUtils.removeFromCache(thread); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graph.setTheme(theme); + this.graph.refresh({ force: true }); + }, + + toString: () => "[object MemoryFlameGraphView]", +}); + +EventEmitter.decorate(MemoryFlameGraphView); + +exports.MemoryFlameGraphView = MemoryFlameGraphView; diff --git a/devtools/client/performance/views/details-waterfall.js b/devtools/client/performance/views/details-waterfall.js new file mode 100644 index 0000000000..975aa43874 --- /dev/null +++ b/devtools/client/performance/views/details-waterfall.js @@ -0,0 +1,282 @@ +/* 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/. */ +/* globals $, PerformanceController, OverviewView, DetailsView */ +"use strict"; + +const { extend } = require("devtools/shared/extend"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { + setNamedTimeout, + clearNamedTimeout, +} = require("devtools/client/shared/widgets/view-helpers"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); + +const EVENTS = require("devtools/client/performance/events"); +const WaterfallUtils = require("devtools/client/performance/modules/logic/waterfall-utils"); +const { + TickUtils, +} = require("devtools/client/performance/modules/waterfall-ticks"); +const { + MarkerDetails, +} = require("devtools/client/performance/modules/widgets/marker-details"); +const { + DetailsSubview, +} = require("devtools/client/performance/views/details-abstract-subview"); + +const Waterfall = React.createFactory( + require("devtools/client/performance/components/Waterfall") +); + +const MARKER_DETAILS_WIDTH = 200; +// Units are in milliseconds. +const WATERFALL_RESIZE_EVENTS_DRAIN = 100; + +/** + * Waterfall view containing the timeline markers, controlled by DetailsView. + */ +const WaterfallView = extend(DetailsSubview, { + // Smallest unit of time between two markers. Larger by 10x^3 than Number.EPSILON. + MARKER_EPSILON: 0.000000000001, + // px + WATERFALL_MARKER_SIDEBAR_WIDTH: 175, + // px + WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS: 20, + + observedPrefs: ["hidden-markers"], + + rerenderPrefs: ["hidden-markers"], + + // Units are in milliseconds. + rangeChangeDebounceTime: 75, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + DetailsSubview.initialize.call(this); + + this._cache = new WeakMap(); + + this._onMarkerSelected = this._onMarkerSelected.bind(this); + this._onResize = this._onResize.bind(this); + this._onViewSource = this._onViewSource.bind(this); + this._onShowAllocations = this._onShowAllocations.bind(this); + this._hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + this.treeContainer = $("#waterfall-tree"); + this.detailsContainer = $("#waterfall-details"); + this.detailsSplitter = $("#waterfall-view > splitter"); + + this.details = new MarkerDetails( + $("#waterfall-details"), + $("#waterfall-view > splitter") + ); + this.details.hidden = true; + + this.details.on("resize", this._onResize); + this.details.on("view-source", this._onViewSource); + this.details.on("show-allocations", this._onShowAllocations); + window.addEventListener("resize", this._onResize); + + // TODO bug 1167093 save the previously set width, and ensure minimum width + this.details.width = MARKER_DETAILS_WIDTH; + }, + + /** + * Unbinds events. + */ + destroy: function() { + DetailsSubview.destroy.call(this); + + clearNamedTimeout("waterfall-resize"); + + this._cache = null; + + this.details.off("resize", this._onResize); + this.details.off("view-source", this._onViewSource); + this.details.off("show-allocations", this._onShowAllocations); + window.removeEventListener("resize", this._onResize); + + ReactDOM.unmountComponentAtNode(this.treeContainer); + }, + + /** + * Method for handling all the set up for rendering a new waterfall. + * + * @param object interval [optional] + * The { startTime, endTime }, in milliseconds. + */ + render: function(interval = {}) { + const recording = PerformanceController.getCurrentRecording(); + if (recording.isRecording()) { + return; + } + const startTime = interval.startTime || 0; + const endTime = interval.endTime || recording.getDuration(); + const markers = recording.getMarkers(); + const rootMarkerNode = this._prepareWaterfallTree(markers); + + this._populateWaterfallTree(rootMarkerNode, { startTime, endTime }); + this.emit(EVENTS.UI_WATERFALL_RENDERED); + }, + + /** + * Called when a marker is selected in the waterfall view, + * updating the markers detail view. + */ + _onMarkerSelected: function(event, marker) { + const recording = PerformanceController.getCurrentRecording(); + const frames = recording.getFrames(); + const allocations = recording.getConfiguration().withAllocations; + + if (event === "selected") { + this.details.render({ marker, frames, allocations }); + this.details.hidden = false; + } + if (event === "unselected") { + this.details.empty(); + } + }, + + /** + * Called when the marker details view is resized. + */ + _onResize: function() { + setNamedTimeout("waterfall-resize", WATERFALL_RESIZE_EVENTS_DRAIN, () => { + this.render(OverviewView.getTimeInterval()); + }); + }, + + /** + * Called whenever an observed pref is changed. + */ + _onObservedPrefChange: function(prefName) { + this._hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + // Clear the cache as we'll need to recompute the collapsed + // marker model + this._cache = new WeakMap(); + }, + + /** + * Called when MarkerDetails view emits an event to view source. + */ + _onViewSource: function(data) { + PerformanceController.viewSourceInDebugger( + data.url, + data.line, + data.column + ); + }, + + /** + * Called when MarkerDetails view emits an event to snap to allocations. + */ + _onShowAllocations: function(data) { + let { endTime } = data; + let startTime = 0; + const recording = PerformanceController.getCurrentRecording(); + const markers = recording.getMarkers(); + + let lastGCMarkerFromPreviousCycle = null; + let lastGCMarker = null; + // Iterate over markers looking for the most recent GC marker + // from the cycle before the marker's whose allocations we're interested in. + for (const marker of markers) { + // We found the marker whose allocations we're tracking; abort + if (marker.start === endTime) { + break; + } + + if (marker.name === "GarbageCollection") { + if (lastGCMarker && lastGCMarker.cycle !== marker.cycle) { + lastGCMarkerFromPreviousCycle = lastGCMarker; + } + lastGCMarker = marker; + } + } + + if (lastGCMarkerFromPreviousCycle) { + startTime = lastGCMarkerFromPreviousCycle.end; + } + + // Adjust times so we don't include the range of these markers themselves. + endTime -= this.MARKER_EPSILON; + startTime += startTime !== 0 ? this.MARKER_EPSILON : 0; + + OverviewView.setTimeInterval({ startTime, endTime }); + DetailsView.selectView("memory-calltree"); + }, + + /** + * Called when the recording is stopped and prepares data to + * populate the waterfall tree. + */ + _prepareWaterfallTree: function(markers) { + const cached = this._cache.get(markers); + if (cached) { + return cached; + } + + const rootMarkerNode = WaterfallUtils.createParentNode({ name: "(root)" }); + + WaterfallUtils.collapseMarkersIntoNode({ + rootNode: rootMarkerNode, + markersList: markers, + filter: this._hiddenMarkers, + }); + + this._cache.set(markers, rootMarkerNode); + return rootMarkerNode; + }, + + /** + * Calculates the available width for the waterfall. + * This should be invoked every time the container node is resized. + */ + _recalculateBounds: function() { + this.waterfallWidth = + this.treeContainer.clientWidth - + this.WATERFALL_MARKER_SIDEBAR_WIDTH - + this.WATERFALL_MARKER_SIDEBAR_SAFE_BOUNDS; + }, + + /** + * Renders the waterfall tree. + */ + _populateWaterfallTree: function(rootMarkerNode, interval) { + this._recalculateBounds(); + + const doc = this.treeContainer.ownerDocument; + const startTime = interval.startTime | 0; + const endTime = interval.endTime | 0; + const dataScale = this.waterfallWidth / (endTime - startTime); + + this.canvas = TickUtils.drawWaterfallBackground( + doc, + dataScale, + this.waterfallWidth + ); + + const treeView = Waterfall({ + marker: rootMarkerNode, + startTime, + endTime, + dataScale, + sidebarWidth: this.WATERFALL_MARKER_SIDEBAR_WIDTH, + waterfallWidth: this.waterfallWidth, + onFocus: node => this._onMarkerSelected("selected", node), + }); + + ReactDOM.render(treeView, this.treeContainer); + }, + + toString: () => "[object WaterfallView]", +}); + +EventEmitter.decorate(WaterfallView); + +exports.WaterfallView = WaterfallView; diff --git a/devtools/client/performance/views/details.js b/devtools/client/performance/views/details.js new file mode 100644 index 0000000000..67c97d6bb5 --- /dev/null +++ b/devtools/client/performance/views/details.js @@ -0,0 +1,295 @@ +/* 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/. */ +/* globals $, $$, PerformanceController */ + +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); + +const { + WaterfallView, +} = require("devtools/client/performance/views/details-waterfall"); +const { + JsCallTreeView, +} = require("devtools/client/performance/views/details-js-call-tree"); +const { + JsFlameGraphView, +} = require("devtools/client/performance/views/details-js-flamegraph"); +const { + MemoryCallTreeView, +} = require("devtools/client/performance/views/details-memory-call-tree"); +const { + MemoryFlameGraphView, +} = require("devtools/client/performance/views/details-memory-flamegraph"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * Details view containing call trees, flamegraphs and markers waterfall. + * Manages subviews and toggles visibility between them. + */ +const DetailsView = { + /** + * Name to (node id, view object, actor requirements, pref killswitch) + * mapping of subviews. + */ + components: { + waterfall: { + id: "waterfall-view", + view: WaterfallView, + features: ["withMarkers"], + }, + "js-calltree": { + id: "js-profile-view", + view: JsCallTreeView, + }, + "js-flamegraph": { + id: "js-flamegraph-view", + view: JsFlameGraphView, + }, + "memory-calltree": { + id: "memory-calltree-view", + view: MemoryCallTreeView, + features: ["withAllocations"], + }, + "memory-flamegraph": { + id: "memory-flamegraph-view", + view: MemoryFlameGraphView, + features: ["withAllocations"], + prefs: ["enable-memory-flame"], + }, + }, + + /** + * Sets up the view with event binding, initializes subviews. + */ + async initialize() { + this.el = $("#details-pane"); + this.toolbar = $("#performance-toolbar-controls-detail-views"); + + this._onViewToggle = this._onViewToggle.bind(this); + this._onRecordingStoppedOrSelected = this._onRecordingStoppedOrSelected.bind( + this + ); + this.setAvailableViews = this.setAvailableViews.bind(this); + + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.addEventListener("command", this._onViewToggle); + } + + await this.setAvailableViews(); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.on(EVENTS.PREF_CHANGED, this.setAvailableViews); + }, + + /** + * Unbinds events, destroys subviews. + */ + async destroy() { + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.removeEventListener("command", this._onViewToggle); + } + + for (const component of Object.values(this.components)) { + component.initialized && (await component.view.destroy()); + } + + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingStoppedOrSelected + ); + PerformanceController.off(EVENTS.PREF_CHANGED, this.setAvailableViews); + }, + + /** + * Sets the possible views based off of recording features and server actor support + * by hiding/showing the buttons that select them and going to default view + * if currently selected. Called when a preference changes in + * `devtools.performance.ui.`. + */ + async setAvailableViews() { + const recording = PerformanceController.getCurrentRecording(); + const isCompleted = recording && recording.isCompleted(); + let invalidCurrentView = false; + + for (const [name, { view }] of Object.entries(this.components)) { + const isSupported = this._isViewSupported(name); + + $(`toolbarbutton[data-view=${name}]`).hidden = !isSupported; + + // If the view is currently selected and not supported, go back to the + // default view. + if (!isSupported && this.isViewSelected(view)) { + invalidCurrentView = true; + } + } + + // Two scenarios in which we select the default view. + // + // 1: If we currently have selected a view that is no longer valid due + // to feature support, and this isn't the first view, and the current recording + // is completed. + // + // 2. If we have a finished recording and no panel was selected yet, + // use a default now that we have the recording configurations + if ( + (this._initialized && isCompleted && invalidCurrentView) || + (!this._initialized && isCompleted && recording) + ) { + await this.selectDefaultView(); + } + }, + + /** + * Takes a view name and determines if the current recording + * can support the view. + * + * @param {string} viewName + * @return {boolean} + */ + _isViewSupported: function(viewName) { + const { features, prefs } = this.components[viewName]; + const recording = PerformanceController.getCurrentRecording(); + + if (!recording || !recording.isCompleted()) { + return false; + } + + const prefSupported = prefs?.length + ? prefs.every(p => PerformanceController.getPref(p)) + : true; + return PerformanceController.isFeatureSupported(features) && prefSupported; + }, + + /** + * Select one of the DetailView's subviews to be rendered, + * hiding the others. + * + * @param String viewName + * Name of the view to be shown. + */ + async selectView(viewName) { + const component = this.components[viewName]; + this.el.selectedPanel = $("#" + component.id); + + await this._whenViewInitialized(component); + + for (const button of $$("toolbarbutton[data-view]", this.toolbar)) { + if (button.getAttribute("data-view") === viewName) { + button.setAttribute("checked", true); + } else { + button.removeAttribute("checked"); + } + } + + // Set a flag indicating that a view was explicitly set based on a + // recording's features. + this._initialized = true; + + this.emit(EVENTS.UI_DETAILS_VIEW_SELECTED, viewName); + }, + + /** + * Selects a default view based off of protocol support + * and preferences enabled. + */ + selectDefaultView: function() { + // We want the waterfall to be default view in almost all cases, except when + // timeline actor isn't supported, or we have markers disabled (which should only + // occur temporarily via bug 1156499 + if (this._isViewSupported("waterfall")) { + return this.selectView("waterfall"); + } + // The JS CallTree should always be supported since the profiler + // actor is as old as the world. + return this.selectView("js-calltree"); + }, + + /** + * Checks if the provided view is currently selected. + * + * @param object viewObject + * @return boolean + */ + isViewSelected: function(viewObject) { + // If not initialized, and we have no recordings, + // no views are selected (even though there's a selected panel) + if (!this._initialized) { + return false; + } + + const selectedPanel = this.el.selectedPanel; + const selectedId = selectedPanel.id; + + for (const { id, view } of Object.values(this.components)) { + if (id == selectedId && view == viewObject) { + return true; + } + } + + return false; + }, + + /** + * Initializes a subview if it wasn't already set up, and makes sure + * it's populated with recording data if there is some available. + * + * @param object component + * A component descriptor from DetailsView.components + */ + async _whenViewInitialized(component) { + if (component.initialized) { + return; + } + component.initialized = true; + await component.view.initialize(); + + // If this view is initialized *after* a recording is shown, it won't display + // any data. Make sure it's populated by setting `shouldUpdateWhenShown`. + // All detail views require a recording to be complete, so do not + // attempt to render if recording is in progress or does not exist. + const recording = PerformanceController.getCurrentRecording(); + if (recording && recording.isCompleted()) { + component.view.shouldUpdateWhenShown = true; + } + }, + + /** + * Called when recording stops or is selected. + */ + _onRecordingStoppedOrSelected: function(state, recording) { + if (typeof state === "string" && state !== "recording-stopped") { + return; + } + this.setAvailableViews(); + }, + + /** + * Called when a view button is clicked. + */ + _onViewToggle: function(e) { + this.selectView(e.target.getAttribute("data-view")); + }, + + toString: () => "[object DetailsView]", +}; + +/** + * Convenient way of emitting events from the view. + */ +EventEmitter.decorate(DetailsView); + +exports.DetailsView = DetailsView; diff --git a/devtools/client/performance/views/moz.build b/devtools/client/performance/views/moz.build new file mode 100644 index 0000000000..d10fbf3dac --- /dev/null +++ b/devtools/client/performance/views/moz.build @@ -0,0 +1,17 @@ +# 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( + "details-abstract-subview.js", + "details-js-call-tree.js", + "details-js-flamegraph.js", + "details-memory-call-tree.js", + "details-memory-flamegraph.js", + "details-waterfall.js", + "details.js", + "overview.js", + "recordings.js", + "toolbar.js", +) diff --git a/devtools/client/performance/views/overview.js b/devtools/client/performance/views/overview.js new file mode 100644 index 0000000000..9257a48fff --- /dev/null +++ b/devtools/client/performance/views/overview.js @@ -0,0 +1,457 @@ +/* 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/. */ +/* globals $ PerformanceController */ +"use strict"; + +// No sense updating the overview more often than receiving data from the +// backend. Make sure this isn't lower than DEFAULT_TIMELINE_DATA_PULL_TIMEOUT +// in devtools/server/actors/timeline.js + +const EVENTS = require("devtools/client/performance/events"); +const { + GraphsController, +} = require("devtools/client/performance/modules/widgets/graphs"); + +const EventEmitter = require("devtools/shared/event-emitter"); + +// The following units are in milliseconds. +const OVERVIEW_UPDATE_INTERVAL = 200; +const FRAMERATE_GRAPH_LOW_RES_INTERVAL = 100; +const FRAMERATE_GRAPH_HIGH_RES_INTERVAL = 16; +const GRAPH_REQUIREMENTS = { + timeline: { + features: ["withMarkers"], + }, + framerate: { + features: ["withTicks"], + }, + memory: { + features: ["withMemory"], + }, +}; + +/** + * View handler for the overview panel's time view, displaying + * framerate, timeline and memory over time. + */ +const OverviewView = { + /** + * How frequently we attempt to render the graphs. Overridden + * in tests. + */ + OVERVIEW_UPDATE_INTERVAL: OVERVIEW_UPDATE_INTERVAL, + + /** + * Sets up the view with event binding. + */ + initialize: function() { + this.graphs = new GraphsController({ + root: $("#overview-pane"), + getFilter: () => PerformanceController.getPref("hidden-markers"), + getTheme: () => PerformanceController.getTheme(), + }); + + // If no timeline support, shut it all down. + if (!PerformanceController.getTraits().features.withMarkers) { + this.disable(); + return; + } + + // Store info on multiprocess support. + this._multiprocessData = PerformanceController.getMultiprocessStatus(); + + this._onRecordingStateChange = this._onRecordingStateChange.bind(this); + this._onRecordingSelected = this._onRecordingSelected.bind(this); + this._onRecordingTick = this._onRecordingTick.bind(this); + this._onGraphSelecting = this._onGraphSelecting.bind(this); + this._onGraphRendered = this._onGraphRendered.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + this._onThemeChanged = this._onThemeChanged.bind(this); + + // Toggle the initial visibility of memory and framerate graph containers + // based off of prefs. + PerformanceController.on(EVENTS.PREF_CHANGED, this._onPrefChanged); + PerformanceController.on(EVENTS.THEME_CHANGED, this._onThemeChanged); + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.on( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + this.graphs.on("selecting", this._onGraphSelecting); + this.graphs.on("rendered", this._onGraphRendered); + }, + + /** + * Unbinds events. + */ + async destroy() { + PerformanceController.off(EVENTS.PREF_CHANGED, this._onPrefChanged); + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.off( + EVENTS.RECORDING_SELECTED, + this._onRecordingSelected + ); + this.graphs.off("selecting", this._onGraphSelecting); + this.graphs.off("rendered", this._onGraphRendered); + await this.graphs.destroy(); + }, + + /** + * Returns true if any of the overview graphs have mouse dragging active, + * false otherwise. + */ + get isMouseActive() { + // Fetch all graphs currently stored in the GraphsController. + // These graphs are not necessarily active, but will not have + // an active mouse, in that case. + return !!this.graphs.getWidgets().some(e => e.isMouseActive); + }, + + /** + * Disabled in the event we're using a Timeline mock, so we'll have no + * timeline, ticks or memory data to show, so just block rendering and hide + * the panel. + */ + disable: function() { + this._disabled = true; + this.graphs.disableAll(); + }, + + /** + * Returns the disabled status. + * + * @return boolean + */ + isDisabled: function() { + return this._disabled; + }, + + /** + * Sets the time interval selection for all graphs in this overview. + * + * @param object interval + * The { startTime, endTime }, in milliseconds. + */ + setTimeInterval: function(interval, options = {}) { + const recording = PerformanceController.getCurrentRecording(); + if (recording == null) { + throw new Error( + "A recording should be available in order to set the selection." + ); + } + if (this.isDisabled()) { + return; + } + const mapStart = () => 0; + const mapEnd = () => recording.getDuration(); + const selection = { start: interval.startTime, end: interval.endTime }; + this._stopSelectionChangeEventPropagation = options.stopPropagation; + this.graphs.setMappedSelection(selection, { mapStart, mapEnd }); + this._stopSelectionChangeEventPropagation = false; + }, + + /** + * Gets the time interval selection for all graphs in this overview. + * + * @return object + * The { startTime, endTime }, in milliseconds. + */ + getTimeInterval: function() { + const recording = PerformanceController.getCurrentRecording(); + if (recording == null) { + throw new Error( + "A recording should be available in order to get the selection." + ); + } + if (this.isDisabled()) { + return { startTime: 0, endTime: recording.getDuration() }; + } + const mapStart = () => 0; + const mapEnd = () => recording.getDuration(); + const selection = this.graphs.getMappedSelection({ mapStart, mapEnd }); + // If no selection returned, this means the overview graphs have not been rendered + // yet, so act as if we have no selection (the full recording). Also + // if the selection range distance is tiny, assume the range was cleared or just + // clicked, and we do not have a range. + if (!selection || selection.max - selection.min < 1) { + return { startTime: 0, endTime: recording.getDuration() }; + } + return { startTime: selection.min, endTime: selection.max }; + }, + + /** + * Method for handling all the set up for rendering the overview graphs. + * + * @param number resolution + * The fps graph resolution. @see Graphs.js + */ + async render(resolution) { + if (this.isDisabled()) { + return; + } + + const recording = PerformanceController.getCurrentRecording(); + await this.graphs.render(recording.getAllData(), resolution); + + // Finished rendering all graphs in this overview. + this.emit(EVENTS.UI_OVERVIEW_RENDERED, resolution); + }, + + /** + * Called at most every OVERVIEW_UPDATE_INTERVAL milliseconds + * and uses data fetched from the controller to render + * data into all the corresponding overview graphs. + */ + async _onRecordingTick() { + await this.render(FRAMERATE_GRAPH_LOW_RES_INTERVAL); + this._prepareNextTick(); + }, + + /** + * Called to refresh the timer to keep firing _onRecordingTick. + */ + _prepareNextTick: function() { + // Check here to see if there's still a _timeoutId, incase + // `stop` was called before the _prepareNextTick call was executed. + if (this.isRendering()) { + this._timeoutId = setTimeout( + this._onRecordingTick, + this.OVERVIEW_UPDATE_INTERVAL + ); + } + }, + + /** + * Called when recording state changes. + */ + _onRecordingStateChange: OverviewViewOnStateChange(async function( + state, + recording + ) { + if (state !== "recording-stopped") { + return; + } + // Check to see if the recording that just stopped is the current recording. + // If it is, render the high-res graphs. For manual recordings, it will also + // be the current recording, but profiles generated by `console.profile` can stop + // while having another profile selected -- in this case, OverviewView should keep + // rendering the current recording. + if (recording !== PerformanceController.getCurrentRecording()) { + return; + } + await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + await this._checkSelection(recording); + }), + + /** + * Called when a new recording is selected. + */ + _onRecordingSelected: OverviewViewOnStateChange(async function(recording) { + this._setGraphVisibilityFromRecordingFeatures(recording); + + // If this recording is complete, render the high res graph + if (recording.isCompleted()) { + await this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + } + await this._checkSelection(recording); + this.graphs.dropSelection(); + }), + + /** + * Start the polling for rendering the overview graph. + */ + _startPolling: function() { + this._timeoutId = setTimeout( + this._onRecordingTick, + this.OVERVIEW_UPDATE_INTERVAL + ); + }, + + /** + * Stop the polling for rendering the overview graph. + */ + _stopPolling: function() { + clearTimeout(this._timeoutId); + this._timeoutId = null; + }, + + /** + * Whether or not the overview view is in a state of polling rendering. + */ + isRendering: function() { + return !!this._timeoutId; + }, + + /** + * Makes sure the selection is enabled or disabled in all the graphs, + * based on whether a recording currently exists and is not in progress. + */ + async _checkSelection(recording) { + const isEnabled = recording ? recording.isCompleted() : false; + await this.graphs.selectionEnabled(isEnabled); + }, + + /** + * Fired when the graph selection has changed. Called by + * mouseup and scroll events. + */ + _onGraphSelecting: function() { + if (this._stopSelectionChangeEventPropagation) { + return; + } + + this.emit(EVENTS.UI_OVERVIEW_RANGE_SELECTED, this.getTimeInterval()); + }, + + _onGraphRendered: function(graphName) { + switch (graphName) { + case "timeline": + this.emit(EVENTS.UI_MARKERS_GRAPH_RENDERED); + break; + case "memory": + this.emit(EVENTS.UI_MEMORY_GRAPH_RENDERED); + break; + case "framerate": + this.emit(EVENTS.UI_FRAMERATE_GRAPH_RENDERED); + break; + } + }, + + /** + * Called whenever a preference in `devtools.performance.ui.` changes. + * Does not care about the enabling of memory/framerate graphs, + * because those will set values on a recording model, and + * the graphs will render based on the existence. + */ + async _onPrefChanged(prefName, prefValue) { + switch (prefName) { + case "hidden-markers": { + const graph = await this.graphs.isAvailable("timeline"); + if (graph) { + const filter = PerformanceController.getPref("hidden-markers"); + graph.setFilter(filter); + graph.refresh({ force: true }); + } + break; + } + } + }, + + _setGraphVisibilityFromRecordingFeatures: function(recording) { + for (const [graphName, requirements] of Object.entries( + GRAPH_REQUIREMENTS + )) { + this.graphs.enable( + graphName, + PerformanceController.isFeatureSupported(requirements.features) + ); + } + }, + + /** + * Fetch the multiprocess status and if e10s is not currently on, disable + * realtime rendering. + * + * @return {boolean} + */ + isRealtimeRenderingEnabled: function() { + return this._multiprocessData.enabled; + }, + + /** + * Show the graphs overview panel when a recording is finished + * when non-realtime graphs are enabled. Also set the graph visibility + * so the performance graphs know which graphs to render. + * + * @param {RecordingModel} recording + */ + _showGraphsPanel: function(recording) { + this._setGraphVisibilityFromRecordingFeatures(recording); + $("#overview-pane").classList.remove("hidden"); + }, + + /** + * Hide the graphs container completely. + */ + _hideGraphsPanel: function() { + $("#overview-pane").classList.add("hidden"); + }, + + /** + * Called when `devtools.theme` changes. + */ + _onThemeChanged: function(theme) { + this.graphs.setTheme({ theme, redraw: true }); + }, + + toString: () => "[object OverviewView]", +}; + +/** + * Utility that can wrap a method of OverviewView that + * handles a recording state change like when a recording is starting, + * stopping, or about to start/stop, and determines whether or not + * the polling for rendering the overview graphs needs to start or stop. + * Must be called with the OverviewView context. + * + * @param {function?} fn + * @return {function} + */ +function OverviewViewOnStateChange(fn) { + return function _onRecordingStateChange(recording) { + // Normalize arguments for the RECORDING_STATE_CHANGE event, + // as it also has a `state` argument. + if (typeof recording === "string") { + recording = arguments[1]; + } + + const currentRecording = PerformanceController.getCurrentRecording(); + + // All these methods require a recording to exist selected and + // from the event name, since there is a delay between starting + // a recording and changing the selection. + if (!currentRecording || !recording) { + // If no recording (this can occur when having a console.profile recording, and + // we do not stop it from the backend), and we are still rendering updates, + // stop that. + if (this.isRendering()) { + this._stopPolling(); + } + return; + } + + // If realtime rendering is not enabed (e10s not on), then + // show the disabled message, or the full graphs if the recording is completed + if (!this.isRealtimeRenderingEnabled()) { + if (recording.isRecording()) { + this._hideGraphsPanel(); + // Abort, as we do not want to change polling status. + return; + } + this._showGraphsPanel(recording); + } + + if (this.isRendering() && !currentRecording.isRecording()) { + this._stopPolling(); + } else if (currentRecording.isRecording() && !this.isRendering()) { + this._startPolling(); + } + + if (fn) { + fn.apply(this, arguments); + } + }; +} + +// Decorates the OverviewView as an EventEmitter +EventEmitter.decorate(OverviewView); + +exports.OverviewView = OverviewView; diff --git a/devtools/client/performance/views/recordings.js b/devtools/client/performance/views/recordings.js new file mode 100644 index 0000000000..d73b1ac083 --- /dev/null +++ b/devtools/client/performance/views/recordings.js @@ -0,0 +1,249 @@ +/* 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/. */ +/* globals $, PerformanceController */ +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); +const { L10N } = require("devtools/client/performance/modules/global"); + +const PerformanceUtils = require("devtools/client/performance/modules/utils"); + +const React = require("devtools/client/shared/vendor/react"); +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); +const RecordingList = React.createFactory( + require("devtools/client/performance/components/RecordingList") +); +const RecordingListItem = React.createFactory( + require("devtools/client/performance/components/RecordingListItem") +); + +const EventEmitter = require("devtools/shared/event-emitter"); + +/** + * Functions handling the recordings UI. + */ +const RecordingsView = { + /** + * Initialization function, called when the tool is started. + */ + initialize: function() { + this._onSelect = this._onSelect.bind(this); + this._onRecordingStateChange = this._onRecordingStateChange.bind(this); + this._onNewRecording = this._onNewRecording.bind(this); + this._onSaveButtonClick = this._onSaveButtonClick.bind(this); + this._onRecordingDeleted = this._onRecordingDeleted.bind(this); + this._onRecordingExported = this._onRecordingExported.bind(this); + + PerformanceController.on( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.on(EVENTS.RECORDING_ADDED, this._onNewRecording); + PerformanceController.on( + EVENTS.RECORDING_DELETED, + this._onRecordingDeleted + ); + PerformanceController.on( + EVENTS.RECORDING_EXPORTED, + this._onRecordingExported + ); + + // DE-XUL: Begin migrating the recording sidebar to React. Temporarily hold state + // here. + this._listState = { + recordings: [], + labels: new WeakMap(), + selected: null, + }; + this._listMount = PerformanceUtils.createHtmlMount( + $("#recording-list-mount") + ); + this._renderList(); + }, + + /** + * Get the index of the currently selected recording. Only used by tests. + * @return {integer} index + */ + getSelectedIndex() { + const { recordings, selected } = this._listState; + return recordings.indexOf(selected); + }, + + /** + * Set the currently selected recording via its index. Only used by tests. + * @param {integer} index + */ + setSelectedByIndex(index) { + this._onSelect(this._listState.recordings[index]); + this._renderList(); + }, + + /** + * DE-XUL: During the migration, this getter will access the selected recording from + * the private _listState object so that tests will continue to pass. + */ + get selected() { + return this._listState.selected; + }, + + /** + * DE-XUL: During the migration, this getter will access the number of recordings. + */ + get itemCount() { + return this._listState.recordings.length; + }, + + /** + * DE-XUL: Render the recording list using React. + */ + _renderList: function() { + const { recordings, labels, selected } = this._listState; + + const recordingList = RecordingList({ + itemComponent: RecordingListItem, + items: recordings.map(recording => ({ + onSelect: () => this._onSelect(recording), + onSave: () => this._onSaveButtonClick(recording), + isLoading: !recording.isRecording() && !recording.isCompleted(), + isRecording: recording.isRecording(), + isSelected: recording === selected, + duration: recording.getDuration().toFixed(0), + label: labels.get(recording), + })), + }); + + ReactDOM.render(recordingList, this._listMount); + }, + + /** + * Destruction function, called when the tool is closed. + */ + destroy: function() { + PerformanceController.off( + EVENTS.RECORDING_STATE_CHANGE, + this._onRecordingStateChange + ); + PerformanceController.off(EVENTS.RECORDING_ADDED, this._onNewRecording); + PerformanceController.off( + EVENTS.RECORDING_DELETED, + this._onRecordingDeleted + ); + PerformanceController.off( + EVENTS.RECORDING_EXPORTED, + this._onRecordingExported + ); + }, + + /** + * Called when a new recording is stored in the UI. This handles + * when recordings are lazily loaded (like a console.profile occurring + * before the tool is loaded) or imported. In normal manual recording cases, + * this will also be fired. + */ + _onNewRecording: function(recording) { + this._onRecordingStateChange(null, recording); + }, + + /** + * Signals that a recording has changed state. + * + * @param string state + * Can be "recording-started", "recording-stopped", "recording-stopping" + * @param RecordingModel recording + * Model of the recording that was started. + */ + _onRecordingStateChange: function(state, recording) { + const { recordings, labels } = this._listState; + + if (!recordings.includes(recording)) { + recordings.push(recording); + labels.set( + recording, + recording.getLabel() || + L10N.getFormatStr("recordingsList.itemLabel", recordings.length) + ); + + // If this is a manual recording, immediately select it, or + // select a console profile if its the only one + if (!recording.isConsole() || !this._listState.selected) { + this._onSelect(recording); + } + } + + // Determine if the recording needs to be selected. + const isCompletedManualRecording = + !recording.isConsole() && recording.isCompleted(); + if (recording.isImported() || isCompletedManualRecording) { + this._onSelect(recording); + } + + this._renderList(); + }, + + /** + * Clears out all non-console recordings. + */ + _onRecordingDeleted: function(recording) { + const { recordings } = this._listState; + const index = recordings.indexOf(recording); + if (index === -1) { + throw new Error("Attempting to remove a recording that doesn't exist."); + } + recordings.splice(index, 1); + this._renderList(); + }, + + /** + * The select listener for this container. + */ + async _onSelect(recording) { + this._listState.selected = recording; + this.emit(EVENTS.UI_RECORDING_SELECTED, recording); + this._renderList(); + }, + + /** + * The click listener for the "save" button of each item in this container. + */ + _onSaveButtonClick: function(recording) { + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init( + window, + L10N.getStr("recordingsList.saveDialogTitle"), + Ci.nsIFilePicker.modeSave + ); + fp.appendFilter( + L10N.getStr("recordingsList.saveDialogJSONFilter"), + "*.json" + ); + fp.appendFilter(L10N.getStr("recordingsList.saveDialogAllFilter"), "*.*"); + fp.defaultString = "profile.json"; + + fp.open({ + done: result => { + if (result == Ci.nsIFilePicker.returnCancel) { + return; + } + this.emit(EVENTS.UI_EXPORT_RECORDING, recording, fp.file); + }, + }); + }, + + _onRecordingExported: function(recording, file) { + if (recording.isConsole()) { + return; + } + const name = file.leafName.replace(/\..+$/, ""); + this._listState.labels.set(recording, name); + this._renderList(); + }, +}; + +/** + * Convenient way of emitting events from the RecordingsView. + */ +EventEmitter.decorate(RecordingsView); + +exports.RecordingsView = window.RecordingsView = RecordingsView; diff --git a/devtools/client/performance/views/toolbar.js b/devtools/client/performance/views/toolbar.js new file mode 100644 index 0000000000..cd156e4c4d --- /dev/null +++ b/devtools/client/performance/views/toolbar.js @@ -0,0 +1,188 @@ +/* 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/. */ +/* globals $, $$, PerformanceController */ +"use strict"; + +const EVENTS = require("devtools/client/performance/events"); +const { + TIMELINE_BLUEPRINT, +} = require("devtools/client/performance/modules/markers"); +const { + MarkerBlueprintUtils, +} = require("devtools/client/performance/modules/marker-blueprint-utils"); + +const { OptionsView } = require("devtools/client/shared/options-view"); +const EventEmitter = require("devtools/shared/event-emitter"); + +const BRANCH_NAME = "devtools.performance.ui."; + +/** + * View handler for toolbar events (mostly option toggling and triggering) + */ +const ToolbarView = { + /** + * Sets up the view with event binding. + */ + async initialize() { + this._onFilterPopupShowing = this._onFilterPopupShowing.bind(this); + this._onFilterPopupHiding = this._onFilterPopupHiding.bind(this); + this._onHiddenMarkersChanged = this._onHiddenMarkersChanged.bind(this); + this._onPrefChanged = this._onPrefChanged.bind(this); + this._popup = $("#performance-options-menupopup"); + + this.optionsView = new OptionsView({ + branchName: BRANCH_NAME, + menupopup: this._popup, + }); + + // Set the visibility of experimental UI options on load + // based off of `devtools.performance.ui.experimental` preference + const experimentalEnabled = PerformanceController.getOption("experimental"); + this._toggleExperimentalUI(experimentalEnabled); + + await this.optionsView.initialize(); + this.optionsView.on("pref-changed", this._onPrefChanged); + + this._buildMarkersFilterPopup(); + this._updateHiddenMarkersPopup(); + $("#performance-filter-menupopup").addEventListener( + "popupshowing", + this._onFilterPopupShowing + ); + $("#performance-filter-menupopup").addEventListener( + "popuphiding", + this._onFilterPopupHiding + ); + }, + + /** + * Unbinds events and cleans up view. + */ + destroy: function() { + $("#performance-filter-menupopup").removeEventListener( + "popupshowing", + this._onFilterPopupShowing + ); + $("#performance-filter-menupopup").removeEventListener( + "popuphiding", + this._onFilterPopupHiding + ); + this._popup = null; + + this.optionsView.off("pref-changed", this._onPrefChanged); + this.optionsView.destroy(); + }, + + /** + * Creates the timeline markers filter popup. + */ + _buildMarkersFilterPopup: function() { + for (const [markerName, markerDetails] of Object.entries( + TIMELINE_BLUEPRINT + )) { + const menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("closemenu", "none"); + menuitem.setAttribute("type", "checkbox"); + menuitem.setAttribute("align", "center"); + menuitem.setAttribute("flex", "1"); + menuitem.setAttribute( + "label", + MarkerBlueprintUtils.getMarkerGenericName(markerName) + ); + menuitem.setAttribute("marker-type", markerName); + menuitem.className = `marker-color-${markerDetails.colorName}`; + + menuitem.addEventListener("command", this._onHiddenMarkersChanged); + + $("#performance-filter-menupopup").appendChild(menuitem); + } + }, + + /** + * Updates the menu items checked state in the timeline markers filter popup. + */ + _updateHiddenMarkersPopup: function() { + const menuItems = $$("#performance-filter-menupopup menuitem[marker-type]"); + const hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + for (const menuitem of menuItems) { + if (~hiddenMarkers.indexOf(menuitem.getAttribute("marker-type"))) { + menuitem.removeAttribute("checked"); + } else { + menuitem.setAttribute("checked", "true"); + } + } + }, + + /** + * Fired when `devtools.performance.ui.experimental` is changed, or + * during init. Toggles the visibility of experimental performance tool options + * in the UI options. + * + * Sets or removes "experimental-enabled" on the menu and main elements, + * hiding or showing all elements with class "experimental-option". + * + * TODO re-enable "#option-enable-memory" permanently once stable in bug 1163350 + * TODO re-enable "#option-show-jit-optimizations" permanently once stable in + * bug 1163351 + * + * @param {boolean} isEnabled + */ + _toggleExperimentalUI: function(isEnabled) { + if (isEnabled) { + $(".theme-body").classList.add("experimental-enabled"); + this._popup.classList.add("experimental-enabled"); + } else { + $(".theme-body").classList.remove("experimental-enabled"); + this._popup.classList.remove("experimental-enabled"); + } + }, + + /** + * Fired when the markers filter popup starts to show. + */ + _onFilterPopupShowing: function() { + $("#filter-button").setAttribute("open", "true"); + }, + + /** + * Fired when the markers filter popup starts to hide. + */ + _onFilterPopupHiding: function() { + $("#filter-button").removeAttribute("open"); + }, + + /** + * Fired when a menu item in the markers filter popup is checked or unchecked. + */ + _onHiddenMarkersChanged: function() { + const checkedMenuItems = $$( + "#performance-filter-menupopup menuitem[marker-type]:not([checked])" + ); + const hiddenMarkers = Array.from(checkedMenuItems, e => + e.getAttribute("marker-type") + ); + PerformanceController.setPref("hidden-markers", hiddenMarkers); + }, + + /** + * Fired when a preference changes in the underlying OptionsView. + * Propogated by the PerformanceController. + */ + _onPrefChanged: function(prefName) { + const value = PerformanceController.getOption(prefName); + + if (prefName === "experimental") { + this._toggleExperimentalUI(value); + } + + this.emit(EVENTS.UI_PREF_CHANGED, prefName, value); + }, + + toString: () => "[object ToolbarView]", +}; + +EventEmitter.decorate(ToolbarView); + +exports.ToolbarView = window.ToolbarView = ToolbarView; |