diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /devtools/client/performance/views | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/performance/views')
-rw-r--r-- | devtools/client/performance/views/details-abstract-subview.js | 194 | ||||
-rw-r--r-- | devtools/client/performance/views/details-js-call-tree.js | 193 | ||||
-rw-r--r-- | devtools/client/performance/views/details-js-flamegraph.js | 125 | ||||
-rw-r--r-- | devtools/client/performance/views/details-memory-call-tree.js | 130 | ||||
-rw-r--r-- | devtools/client/performance/views/details-memory-flamegraph.js | 121 | ||||
-rw-r--r-- | devtools/client/performance/views/details-waterfall.js | 252 | ||||
-rw-r--r-- | devtools/client/performance/views/details.js | 263 | ||||
-rw-r--r-- | devtools/client/performance/views/overview.js | 423 | ||||
-rw-r--r-- | devtools/client/performance/views/recordings.js | 202 | ||||
-rw-r--r-- | devtools/client/performance/views/toolbar.js | 160 |
10 files changed, 2063 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 000000000..86ea45366 --- /dev/null +++ b/devtools/client/performance/views/details-abstract-subview.js @@ -0,0 +1,194 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* exported DetailsSubview */ +"use strict"; + +/** + * A base class from which all detail views inherit. + */ +var 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); + + let self = this; + let originalRenderFn = this.render; + let afterRenderFn = () => { + this._wasRendered = true; + }; + + this.render = Task.async(function* (...args) { + let maybeRetval = yield 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)) { + let 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) { + 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. + let 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; + } + } +}; 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 000000000..6c4e808af --- /dev/null +++ b/devtools/client/performance/views/details-js-call-tree.js @@ -0,0 +1,193 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals DetailsSubview */ +"use strict"; + +/** + * CallTree view containing profiler call tree, controlled by DetailsView. + */ +var JsCallTreeView = Heritage.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 = {}) { + let recording = PerformanceController.getCurrentRecording(); + let profile = recording.getProfile(); + let showOptimizations = PerformanceController.getOption("show-jit-optimizations"); + + let options = { + contentOnly: !PerformanceController.getOption("show-platform-data"), + invertTree: PerformanceController.getOption("invert-call-tree"), + flattenRecursion: PerformanceController.getOption("flatten-tree-recursion"), + showOptimizationHint: showOptimizations + }; + let 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) { + let showOptimizations = PerformanceController.getOption("show-jit-optimizations"); + let frameNode = treeItem.frame; + let optimizationSites = frameNode && frameNode.hasOptimizations() + ? frameNode.getOptimizations().optimizationSites + : []; + + if (!showOptimizations || !frameNode || optimizationSites.length === 0) { + this.hideOptimizations(); + this.emit("focus", treeItem); + return; + } + + this.showOptimizations(); + + let frameData = frameNode.getInfo(); + let optimizations = JITOptimizationsView({ + frameData, + optimizationSites, + onViewSourceInDebugger: (url, line) => { + gToolbox.viewSourceInDebugger(url, line).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) { + let { url, line } = treeItem.frame.getInfo(); + gToolbox.viewSourceInDebugger(url, line).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) { + let thread = profile.threads[0]; + let { contentOnly, invertTree, flattenRecursion } = options; + let 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. + let inverted = options.invertTree && frameNode.samples > 0; + + let 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); 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 000000000..0aca21252 --- /dev/null +++ b/devtools/client/performance/views/details-js-flamegraph.js @@ -0,0 +1,125 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals DetailsSubview */ +"use strict"; + +/** + * FlameGraph view containing a pyramid-like visualization of a profile, + * controlled by DetailsView. + */ +var JsFlameGraphView = Heritage.extend(DetailsSubview, { + + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-platform-data", + "show-idle-blocks" + ], + + /** + * Sets up the view with event binding. + */ + initialize: Task.async(function* () { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#js-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + yield 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. + */ + destroy: Task.async(function* () { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + yield 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 = {}) { + let recording = PerformanceController.getCurrentRecording(); + let duration = recording.getDuration(); + let profile = recording.getProfile(); + let thread = profile.threads[0]; + + let 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 () { + let 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 () { + let recording = PerformanceController.getCurrentRecording(); + let profile = recording.getProfile(); + let 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); 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 000000000..883d92e63 --- /dev/null +++ b/devtools/client/performance/views/details-memory-call-tree.js @@ -0,0 +1,130 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals DetailsSubview */ +"use strict"; + +/** + * CallTree view containing memory allocation sites, controlled by DetailsView. + */ +var MemoryCallTreeView = Heritage.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 = {}) { + let options = { + invertTree: PerformanceController.getOption("invert-call-tree") + }; + let recording = PerformanceController.getCurrentRecording(); + let allocations = recording.getAllocations(); + let 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) { + let { url, line } = treeItem.frame.getInfo(); + gToolbox.viewSourceInDebugger(url, line).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) { + let thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + let { 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. + let inverted = options.invertTree && frameNode.samples > 0; + + let 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); 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 000000000..70eaa3c7a --- /dev/null +++ b/devtools/client/performance/views/details-memory-flamegraph.js @@ -0,0 +1,121 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals DetailsSubview */ +"use strict"; + +/** + * FlameGraph view containing a pyramid-like visualization of memory allocation + * sites, controlled by DetailsView. + */ +var MemoryFlameGraphView = Heritage.extend(DetailsSubview, { + + shouldUpdateWhileMouseIsActive: true, + + rerenderPrefs: [ + "invert-flame-graph", + "flatten-tree-recursion", + "show-idle-blocks" + ], + + /** + * Sets up the view with event binding. + */ + initialize: Task.async(function* () { + DetailsSubview.initialize.call(this); + + this.graph = new FlameGraph($("#memory-flamegraph-view")); + this.graph.timelineTickUnits = L10N.getStr("graphs.ms"); + this.graph.setTheme(PerformanceController.getTheme()); + yield 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. + */ + destroy: Task.async(function* () { + DetailsSubview.destroy.call(this); + + PerformanceController.off(EVENTS.THEME_CHANGED, this._onThemeChanged); + this.graph.off("selecting", this._onRangeChangeInGraph); + + yield 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 = {}) { + let recording = PerformanceController.getCurrentRecording(); + let duration = recording.getDuration(); + let allocations = recording.getAllocations(); + + let thread = RecordingUtils.getProfileThreadFromAllocations(allocations); + let 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 () { + let 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 () { + let recording = PerformanceController.getCurrentRecording(); + let allocations = recording.getAllocations(); + let 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); diff --git a/devtools/client/performance/views/details-waterfall.js b/devtools/client/performance/views/details-waterfall.js new file mode 100644 index 000000000..db8def053 --- /dev/null +++ b/devtools/client/performance/views/details-waterfall.js @@ -0,0 +1,252 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals window, DetailsSubview */ +"use strict"; + +const MARKER_DETAILS_WIDTH = 200; +// Units are in milliseconds. +const WATERFALL_RESIZE_EVENTS_DRAIN = 100; + +const { TickUtils } = require("devtools/client/performance/modules/waterfall-ticks"); + +/** + * Waterfall view containing the timeline markers, controlled by DetailsView. + */ +var WaterfallView = Heritage.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 = {}) { + let recording = PerformanceController.getCurrentRecording(); + if (recording.isRecording()) { + return; + } + let startTime = interval.startTime || 0; + let endTime = interval.endTime || recording.getDuration(); + let markers = recording.getMarkers(); + let 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) { + let recording = PerformanceController.getCurrentRecording(); + let frames = recording.getFrames(); + let 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) { + gToolbox.viewSourceInDebugger(data.url, data.line); + }, + + /** + * Called when MarkerDetails view emits an event to snap to allocations. + */ + _onShowAllocations: function (_, data) { + let { endTime } = data; + let startTime = 0; + let recording = PerformanceController.getCurrentRecording(); + let 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 (let 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) { + let cached = this._cache.get(markers); + if (cached) { + return cached; + } + + let 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(); + + let doc = this.treeContainer.ownerDocument; + let startTime = interval.startTime | 0; + let endTime = interval.endTime | 0; + let dataScale = this.waterfallWidth / (endTime - startTime); + + this.canvas = TickUtils.drawWaterfallBackground(doc, dataScale, this.waterfallWidth); + + let 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); diff --git a/devtools/client/performance/views/details.js b/devtools/client/performance/views/details.js new file mode 100644 index 000000000..95557bc36 --- /dev/null +++ b/devtools/client/performance/views/details.js @@ -0,0 +1,263 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals WaterfallView, JsCallTreeView, JsFlameGraphView, MemoryCallTreeView, + MemoryFlameGraphView */ +"use strict"; + +/** + * Details view containing call trees, flamegraphs and markers waterfall. + * Manages subviews and toggles visibility between them. + */ +var 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. + */ + initialize: Task.async(function* () { + 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 (let button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.addEventListener("command", this._onViewToggle); + } + + yield 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. + */ + destroy: Task.async(function* () { + for (let button of $$("toolbarbutton[data-view]", this.toolbar)) { + button.removeEventListener("command", this._onViewToggle); + } + + for (let component of Object.values(this.components)) { + component.initialized && (yield 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.`. + */ + setAvailableViews: Task.async(function* () { + let recording = PerformanceController.getCurrentRecording(); + let isCompleted = recording && recording.isCompleted(); + let invalidCurrentView = false; + + for (let [name, { view }] of Object.entries(this.components)) { + let 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)) { + yield this.selectDefaultView(); + } + }), + + /** + * Takes a view name and determines if the current recording + * can support the view. + * + * @param {string} viewName + * @return {boolean} + */ + _isViewSupported: function (viewName) { + let { features, prefs } = this.components[viewName]; + let recording = PerformanceController.getCurrentRecording(); + + if (!recording || !recording.isCompleted()) { + return false; + } + + let prefSupported = (prefs && 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. + */ + selectView: Task.async(function* (viewName) { + let component = this.components[viewName]; + this.el.selectedPanel = $("#" + component.id); + + yield this._whenViewInitialized(component); + + for (let 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; + } + + let selectedPanel = this.el.selectedPanel; + let selectedId = selectedPanel.id; + + for (let { 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 + */ + _whenViewInitialized: Task.async(function* (component) { + if (component.initialized) { + return; + } + component.initialized = true; + yield 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. + let 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); diff --git a/devtools/client/performance/views/overview.js b/devtools/client/performance/views/overview.js new file mode 100644 index 000000000..f45a6d844 --- /dev/null +++ b/devtools/client/performance/views/overview.js @@ -0,0 +1,423 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +"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 + +// 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. + */ +var 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. + */ + destroy: Task.async(function* () { + 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); + yield 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 = {}) { + let recording = PerformanceController.getCurrentRecording(); + if (recording == null) { + throw new Error("A recording should be available in order to set the selection."); + } + if (this.isDisabled()) { + return; + } + let mapStart = () => 0; + let mapEnd = () => recording.getDuration(); + let 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 () { + let 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() }; + } + let mapStart = () => 0; + let mapEnd = () => recording.getDuration(); + let 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 + */ + render: Task.async(function* (resolution) { + if (this.isDisabled()) { + return; + } + + let recording = PerformanceController.getCurrentRecording(); + yield 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. + */ + _onRecordingTick: Task.async(function* () { + yield 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(Task.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; + } + this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + yield this._checkSelection(recording); + })), + + /** + * Called when a new recording is selected. + */ + _onRecordingSelected: OverviewViewOnStateChange(Task.async(function* (_, recording) { + this._setGraphVisibilityFromRecordingFeatures(recording); + + // If this recording is complete, render the high res graph + if (recording.isCompleted()) { + yield this.render(FRAMERATE_GRAPH_HIGH_RES_INTERVAL); + } + yield 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. + */ + _checkSelection: Task.async(function* (recording) { + let isEnabled = recording ? recording.isCompleted() : false; + yield 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. + */ + _onPrefChanged: Task.async(function* (_, prefName, prefValue) { + switch (prefName) { + case "hidden-markers": { + let graph = yield this.graphs.isAvailable("timeline"); + if (graph) { + let filter = PerformanceController.getPref("hidden-markers"); + graph.setFilter(filter); + graph.refresh({ force: true }); + } + break; + } + } + }), + + _setGraphVisibilityFromRecordingFeatures: function (recording) { + for (let [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(eventName, recording) { + // Normalize arguments for the RECORDING_STATE_CHANGE event, + // as it also has a `state` argument. + if (typeof recording === "string") { + recording = arguments[2]; + } + + let 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); diff --git a/devtools/client/performance/views/recordings.js b/devtools/client/performance/views/recordings.js new file mode 100644 index 000000000..487ea4f03 --- /dev/null +++ b/devtools/client/performance/views/recordings.js @@ -0,0 +1,202 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals document, window */ +"use strict"; + +/** + * Functions handling the recordings UI. + */ +var 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. + */ + _onSelect: Task.async(function* (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) { + let 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); diff --git a/devtools/client/performance/views/toolbar.js b/devtools/client/performance/views/toolbar.js new file mode 100644 index 000000000..bcab09a86 --- /dev/null +++ b/devtools/client/performance/views/toolbar.js @@ -0,0 +1,160 @@ +/* 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/. */ +/* import-globals-from ../performance-controller.js */ +/* import-globals-from ../performance-view.js */ +/* globals document */ +"use strict"; + +/** + * View handler for toolbar events (mostly option toggling and triggering) + */ +var ToolbarView = { + /** + * Sets up the view with event binding. + */ + initialize: Task.async(function* () { + 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 + let experimentalEnabled = PerformanceController.getOption("experimental"); + this._toggleExperimentalUI(experimentalEnabled); + + yield 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 (let [markerName, markerDetails] of Object.entries(TIMELINE_BLUEPRINT)) { + let menuitem = document.createElement("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 () { + let menuItems = $$("#performance-filter-menupopup menuitem[marker-type]"); + let hiddenMarkers = PerformanceController.getPref("hidden-markers"); + + for (let 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 () { + let checkedMenuItems = + $$("#performance-filter-menupopup menuitem[marker-type]:not([checked])"); + let hiddenMarkers = Array.map(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) { + let value = PerformanceController.getOption(prefName); + + if (prefName === "experimental") { + this._toggleExperimentalUI(value); + } + + this.emit(EVENTS.UI_PREF_CHANGED, prefName, value); + }, + + toString: () => "[object ToolbarView]" +}; + +EventEmitter.decorate(ToolbarView); |