/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; /** * Functions handling the profile inspection UI, showing the framerate and * cateogry graphs, along with a call tree view. * * A profile view is a tabbed browser, so recording data will be displayed in * tabs. Certain messages like 'Loading' or 'Recording...' may also be shown. */ let ProfileView = { /** * Initialization function, called when the tool is started. */ initialize: function() { this._tabs = $("#profile-content tabs"); this._panels = $("#profile-content tabpanels"); this._tabTemplate = $("#profile-content-tab-template"); this._panelTemplate = $("#profile-content-tabpanel-template"); this._newtabButton = $("#profile-newtab-button"); this._invertTree = $("#invert-tree"); this._recordingInfoByPanel = new WeakMap(); this._framerateGraphByPanel = new Map(); this._categoriesGraphByPanel = new Map(); this._callTreeRootByPanel = new Map(); this._onTabSelect = this._onTabSelect.bind(this); this._onNewTabClick = this._onNewTabClick.bind(this); this._onInvertTree = this._onInvertTree.bind(this); this._onGraphLegendSelection = this._onGraphLegendSelection.bind(this); this._onGraphMouseUp = this._onGraphMouseUp.bind(this); this._onGraphScroll = this._onGraphScroll.bind(this); this._onCallViewFocus = this._onCallViewFocus.bind(this); this._onCallViewLink = this._onCallViewLink.bind(this); this._onCallViewZoom = this._onCallViewZoom.bind(this); this._panels.addEventListener("select", this._onTabSelect, false); this._newtabButton.addEventListener("click", this._onNewTabClick, false); this._invertTree.addEventListener("command", this._onInvertTree, false); }, /** * Destruction function, called when the tool is closed. */ destroy: function() { this.removeAllTabs(); this._panels.removeEventListener("select", this._onTabSelect, false); this._newtabButton.removeEventListener("click", this._onNewTabClick, false); this._invertTree.removeEventListener("command", this._onInvertTree, false); }, /** * Shows a message detailing that there are is no data available. * The tabbed browser will also be hidden. */ showEmptyNotice: function() { $("#profile-pane").selectedPanel = $("#empty-notice"); window.emit(EVENTS.EMPTY_NOTICE_SHOWN); }, /** * Shows a message detailing that a recording is currently in progress. * The tabbed browser will also be hidden. */ showRecordingNotice: function() { $("#profile-pane").selectedPanel = $("#recording-notice"); window.emit(EVENTS.RECORDING_NOTICE_SHOWN); }, /** * Shows a message detailing that a finished recording is being loaded. * The tabbed browser will also be hidden. */ showLoadingNotice: function() { $("#profile-pane").selectedPanel = $("#loading-notice"); window.emit(EVENTS.LOADING_NOTICE_SHOWN); }, /** * Shows the tabbed browser displaying recording data. */ showTabbedBrowser: function() { $("#profile-pane").selectedPanel = $("#profile-content"); window.emit(EVENTS.TABBED_BROWSER_SHOWN); }, /** * Selects the tab at the specified index in this tabbed browser. * * @param number tabIndex * The index of the tab to select. If no tab is available at the * specified index, all tabs will be deselected. */ selectTab: function(tabIndex) { $("#profile-content").selectedIndex = tabIndex; }, /** * Adds an empty tab in this tabbed browser. * * @return number * The newly created tab's index. */ addTab: function() { let tab = this._tabs.appendChild(this._tabTemplate.cloneNode(true)); let panel = this._panels.appendChild(this._panelTemplate.cloneNode(true)); // "Uncover" the tab via a CSS animation. tab.removeAttribute("covered"); let tabIndex = this._tabs.itemCount - 1; return tabIndex; }, /** * Sets the title of a tab in this tabbed browser. * * @param number tabIndex * The index of the tab to name. * @param number beginAt, endAt * The 'start → stop' components of the tab title. */ nameTab: function(tabIndex, beginAt, endAt) { let tab = this._getTab(tabIndex); let a = L10N.numberWithDecimals(beginAt, 2); let b = L10N.numberWithDecimals(endAt, 2); let labelNode = $(".tab-title-label", tab); labelNode.setAttribute("value", L10N.getFormatStr("profile.tab", a, b)); }, /** * Populates the panel for a tab in this tabbed browser with the provided * recording data. * * @param number tabIndex * The index of the tab to populate. * @param object recordingData * The profiler and refresh driver ticks data received from the front. * @param number beginAt * The earliest time in the recording data to start at (in milliseconds). * @param number endAt * The latest time in the recording data to end at (in milliseconds). * @param object options * Additional options supported by this operation. * @see ProfileView._populatePanelWidgets */ populateTab: Task.async(function*(tabIndex, recordingData, beginAt, endAt, options) { let tab = this._getTab(tabIndex); let panel = this._getPanel(tabIndex); if (!tab || !panel) { return; } this._recordingInfoByPanel.set(panel, { recordingData: recordingData, displayRange: { beginAt: beginAt, endAt: endAt } }); let { profilerData, ticksData } = recordingData; let categoriesData = RecordingUtils.plotCategoriesFor(profilerData, beginAt, endAt); let framerateData = RecordingUtils.plotFramerateFor(ticksData, beginAt, endAt); RecordingUtils.syncCategoriesWithFramerate(categoriesData, framerateData); yield this._populatePanelWidgets(panel, { profilerData: profilerData, framerateData: framerateData, categoriesData: categoriesData }, beginAt, endAt, options); }), /** * Adds a new tab in this tabbed browser, populates it with the provided * recording data and automatically selects it. * * @param object recordingData * The profiler and refresh driver ticks data received from the front. * @param number beginAt * The earliest time in the recording data to start at (in milliseconds). * @param number endAt * The latest time in the recording data to end at (in milliseconds). * @param object options * Additional options supported by this operation. * @see ProfileView._populatePanelWidgets */ addTabAndPopulate: Task.async(function*(recordingData, beginAt, endAt, options) { let tabIndex = this.addTab(); this.nameTab(tabIndex, beginAt, endAt); // Wait for a few milliseconds before presenting the recording data, // to allow the 'Loading' panel to finish being drawn (if there is one). yield DevToolsUtils.waitForTime(RECORDING_DATA_DISPLAY_DELAY); yield this.populateTab(tabIndex, recordingData, beginAt, endAt, options); this.selectTab(tabIndex); }), /** * Removes all tabs and corresponding views from this tabbed browser. */ removeAllTabs: function() { for (let [, graph] of this._framerateGraphByPanel) graph.destroy(); for (let [, graph] of this._categoriesGraphByPanel) graph.destroy(); for (let [, root] of this._callTreeRootByPanel) root.remove(); this._recordingInfoByPanel = new WeakMap(); this._framerateGraphByPanel.clear(); this._categoriesGraphByPanel.clear(); this._callTreeRootByPanel.clear(); while (this._tabs.hasChildNodes()) { this._tabs.firstChild.remove(); } while (this._panels.hasChildNodes()) { this._panels.firstChild.remove(); } }, /** * Removes all tabs exclusively after the one at the specified index. * * @param number tabIndex * The "leftmost" tab to still keep. Remaining tabs will be removed. */ removeTabsAfter: function(tabIndex) { tabIndex++; while (tabIndex < this._tabs.itemCount) { let tab = this._getTab(tabIndex); let panel = this._getPanel(tabIndex); this._framerateGraphByPanel.delete(panel); this._categoriesGraphByPanel.delete(panel); this._callTreeRootByPanel.delete(panel); tab.remove(); panel.remove(); } }, /** * Gets the total number of tabs displayed in this tabbed browser. * @return number */ get tabCount() { let tabs = this._tabs.childNodes.length; let tabpanels = this._panels.childNodes.length; if (tabs != tabpanels) { throw "The number of tabs isn't equal to the number of tabpanels."; } return tabs; }, /** * Adds a new tab in this tabbed browser, populates it based on the current * selection range in the displayed data and automatically selects it. */ _spawnTabFromSelection: Task.async(function*() { let { recordingData } = this._getRecordingInfo(); let categoriesGraph = this._getCategoriesGraph(); // A selection is assumed to be available in the current tab. let { min: beginAt, max: endAt } = categoriesGraph.getMappedSelection(); // Hide the "new tab" button since a selection won't implicitly be made // in the newly created tab. this._newtabButton.hidden = true; yield this.addTabAndPopulate(recordingData, beginAt, endAt); // Signal that a new tab was spawned from a graph's selection. window.emit(EVENTS.TAB_SPAWNED_FROM_SELECTION); }), /** * Adds a new tab in this tabbed browser, populates it based on the provided * frame node and automatically selects it. * * @param FrameNode frameNode * Information about the function call node in the tree. */ _spawnTabFromFrameNode: Task.async(function*(frameNode) { let { recordingData } = this._getRecordingInfo(); let sampleTimes = frameNode.sampleTimes; let beginAt = sampleTimes[0].start; let endAt = sampleTimes[sampleTimes.length - 1].end; // Hide the "new tab" button since a selection won't implicitly be made // in the newly created tab. this._newtabButton.hidden = true; yield this.addTabAndPopulate(recordingData, beginAt, endAt, { skipCallTree: true }); this._populateCallTreeFromFrameNode(this._getPanel(), frameNode); // Signal that a new tab was spawned from a node in the call tree. window.emit(EVENTS.TAB_SPAWNED_FROM_FRAME_NODE); }), /** * Filters the recording data displayed in the call tree view to match * the current selection range in the graphs. * * @param object options * Additional options supported by this operation. * @see ProfileView._populatePanelWidgets */ _rebuildTreeFromSelection: function(options) { let { recordingData, displayRange } = this._getRecordingInfo(); let categoriesGraph = this._getCategoriesGraph(); let selectedPanel = this._getPanel(); // If there's no selection, get the original display range and hide the // "new tab" button. if (!categoriesGraph.hasSelection()) { let { beginAt, endAt } = displayRange; this._newtabButton.hidden = true; this._populateCallTree(selectedPanel, recordingData.profilerData, beginAt, endAt, options); } // Otherwise, just get the selected display range and only show the // "new tab" button if the selection is wide enough. else { let { min: beginAt, max: endAt } = categoriesGraph.getMappedSelection(); this._newtabButton.hidden = (endAt - beginAt) < GRAPH_ZOOM_MIN_TIMESPAN; this._populateCallTree(selectedPanel, recordingData.profilerData, beginAt, endAt, options); } }, /** * Highlights certain areas in the categories graph to match the currently * selected frame node's sample times in the tree view. * * @param ThreadNode | FrameNode frameNode * The root node data source for this tree. */ _highlightAreaFromFrameNode: function(frameNode) { let categoriesGraph = this._getCategoriesGraph(); if (categoriesGraph) { categoriesGraph.setMask(frameNode.sampleTimes); } }, /** * Populates all the widgets in the specified tab's panel with the provided * data. The already existing widgets will be removed. * * @param nsIDOMNode panel * The element in this . * @param object dataSource * The profiler, framerate and categories data source. * @param number beginAt * The earliest allowed time for tree nodes (in milliseconds). * @param number endAt * The latest allowed time for tree nodes (in milliseconds). * @param object options * Additional options supported by this operation: * - skipCallTree: true if the call tree should not be populated * - skipCallTreeFocus: true if the root node shouldn't be focused */ _populatePanelWidgets: Task.async(function*(panel, dataSource, beginAt, endAt, options = {}) { let { profilerData, framerateData, categoriesData } = dataSource; let framerateGraph = yield this._populateFramerateGraph(panel, framerateData, beginAt); let categoriesGraph = yield this._populateCategoriesGraph(panel, categoriesData, beginAt); CanvasGraphUtils.linkAnimation(framerateGraph, categoriesGraph); CanvasGraphUtils.linkSelection(framerateGraph, categoriesGraph); if (!options.skipCallTree) { this._populateCallTree(panel, profilerData, beginAt, endAt, options); } }), /** * Populates the framerate graph in the specified tab's panel with the * provided data. The already existing graph will be removed. * * @param nsIDOMNode panel * The element in this . * @param object framerateData * The data source for this graph. * @param number beginAt * The earliest time in the recording data to start at (in milliseconds). */ _populateFramerateGraph: Task.async(function*(panel, framerateData, beginAt) { let oldGraph = this._getFramerateGraph(panel); if (oldGraph) { oldGraph.destroy(); } // Don't create a graph if there's not enough data to show. if (!framerateData || framerateData.length < 2) { return null; } let graph = new LineGraphWidget($(".framerate", panel), L10N.getStr("graphs.fps")); graph.fixedHeight = FRAMERATE_GRAPH_HEIGHT; graph.minDistanceBetweenPoints = 1; graph.dataOffsetX = beginAt; yield graph.setDataWhenReady(framerateData); graph.on("mouseup", this._onGraphMouseUp); graph.on("scroll", this._onGraphScroll); this._framerateGraphByPanel.set(panel, graph); return graph; }), /** * Populates the categories graph in the specified tab's panel with the * provided data. The already existing graph will be removed. * * @param nsIDOMNode panel * The element in this . * @param object categoriesData * The data source for this graph. * @param number beginAt * The earliest time in the recording data to start at (in milliseconds). */ _populateCategoriesGraph: Task.async(function*(panel, categoriesData, beginAt) { let oldGraph = this._getCategoriesGraph(panel); if (oldGraph) { oldGraph.destroy(); } // Don't create a graph if there's not enough data to show. if (!categoriesData || categoriesData.length < 2) { return null; } let graph = new BarGraphWidget($(".categories", panel)); graph.fixedHeight = CATEGORIES_GRAPH_HEIGHT; graph.minBarsWidth = CATEGORIES_GRAPH_MIN_BARS_WIDTH; graph.format = CATEGORIES.sort((a, b) => a.ordinal > b.ordinal); graph.dataOffsetX = beginAt; yield graph.setDataWhenReady(categoriesData); graph.on("legend-selection", this._onGraphLegendSelection); graph.on("mouseup", this._onGraphMouseUp); graph.on("scroll", this._onGraphScroll); this._categoriesGraphByPanel.set(panel, graph); return graph; }), /** * Populates the call tree view in the specified tab's panel with the * provided data. The already existing tree will be removed. * * @param nsIDOMNode panel * The element in this . * @param object profilerData * The data source for this tree. * @param number beginAt * The earliest time in the data source to start at (in milliseconds). * @param number endAt * The latest time in the data source to end at (in milliseconds). * @param object options * Additional options supported by this operation. * @see ProfileView._populatePanelWidgets */ _populateCallTree: function(panel, profilerData, beginAt, endAt, options = {}) { let threadSamples = profilerData.profile.threads[0].samples; let contentOnly = !Prefs.showPlatformData; let invertChecked = this._invertTree.hasAttribute("checked"); let threadNode = new ThreadNode(threadSamples, contentOnly, beginAt, endAt, invertChecked); // 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. options.inverted = invertChecked && threadNode.samples > 0; this._populateCallTreeFromFrameNode(panel, threadNode, options); }, /** * Populates the call tree view in the specified tab's panel with the * provided frame node. The already existing tree will be removed. * * @param nsIDOMNode panel * The element in this . * @param ThreadNode | FrameNode frameNode * The root node data source for this tree. * @param object options * Additional options supported by this operation. * @see ProfileView._populatePanelWidgets */ _populateCallTreeFromFrameNode: function(panel, frameNode, options = {}) { let oldRoot = this._getCallTreeRoot(panel); if (oldRoot) { oldRoot.remove(); } let callTreeRoot = new CallView({ autoExpandDepth: options.inverted ? 0 : undefined, frame: frameNode, hidden: options.inverted, inverted: options.inverted }); callTreeRoot.on("focus", this._onCallViewFocus); callTreeRoot.on("link", this._onCallViewLink); callTreeRoot.on("zoom", this._onCallViewZoom); callTreeRoot.attachTo($(".call-tree-cells-container", panel)); if (!options.skipCallTreeFocus) { callTreeRoot.focus(); } let contentOnly = !Prefs.showPlatformData; callTreeRoot.toggleCategories(!contentOnly); this._callTreeRootByPanel.set(panel, callTreeRoot); }, /** * Shortcuts for accessing the recording info or widgets for a . * @param nsIDOMNode panel [optional] * @return object */ _getRecordingInfo: function(panel = this._getPanel()) { return this._recordingInfoByPanel.get(panel); }, _getFramerateGraph: function(panel = this._getPanel()) { return this._framerateGraphByPanel.get(panel); }, _getCategoriesGraph: function(panel = this._getPanel()) { return this._categoriesGraphByPanel.get(panel); }, _getCallTreeRoot: function(panel = this._getPanel()) { return this._callTreeRootByPanel.get(panel); }, _getTab: function(tabIndex = this._getSelectedIndex()) { return this._tabs.childNodes[tabIndex]; }, _getPanel: function(tabIndex = this._getSelectedIndex()) { return this._panels.childNodes[tabIndex]; }, _getSelectedIndex: function() { return $("#profile-content").selectedIndex; }, /** * Listener handling the tab "select" event in this container. */ _onTabSelect: function() { let categoriesGraph = this._getCategoriesGraph(); if (categoriesGraph) { this._newtabButton.hidden = !categoriesGraph.hasSelection(); } else { this._newtabButton.hidden = true; } this.removeTabsAfter(this._getSelectedIndex()); }, /** * Listener handling the new tab "click" event in this container. */ _onNewTabClick: function() { this._spawnTabFromSelection(); }, _onInvertTree: function() { this._rebuildTreeFromSelection(); }, /** * Listener handling the "legend-selection" event for the graphs in this container. */ _onGraphLegendSelection: function() { this._rebuildTreeFromSelection({ skipCallTreeFocus: true }); }, /** * Listener handling the "mouseup" event for the graphs in this container. */ _onGraphMouseUp: function() { this._rebuildTreeFromSelection(); }, /** * Listener handling the "scroll" event for the graphs in this container. */ _onGraphScroll: function() { setNamedTimeout("graph-scroll", GRAPH_SCROLL_EVENTS_DRAIN, () => { this._rebuildTreeFromSelection(); }); }, /** * Listener handling the "focus" event for the call tree in this container. */ _onCallViewFocus: function(event, treeItem) { setNamedTimeout("graph-focus", CALL_VIEW_FOCUS_EVENTS_DRAIN, () => { this._highlightAreaFromFrameNode(treeItem.frame); }); }, /** * Listener handling the "link" event for the call tree in this container. */ _onCallViewLink: function(event, treeItem) { let { url, line } = treeItem.frame.getInfo(); viewSourceInDebugger(url, line); }, /** * Listener handling the "zoom" event for the call tree in this container. */ _onCallViewZoom: function(event, treeItem) { this._spawnTabFromFrameNode(treeItem.frame); } }; /** * Utility functions handling recording data. */ let RecordingUtils = { _frameratePlotsCache: new WeakMap(), /** * Creates an appropriate data source to be displayed in a categories graph * from on the provided profiler data. * * @param object profilerData * The profiler data received from the front. * @param number beginAt * The earliest time in the profiler data to start at (in milliseconds). * @param number endAt * The latest time in the profiler data to end at (in milliseconds). * @return array * A data source useful for a BarGraphWidget. */ plotCategoriesFor: function(profilerData, beginAt, endAt) { let categoriesData = []; let profile = profilerData.profile; let samples = profile.threads[0].samples; // Accumulate the category of each frame for every sample. for (let { frames, time } of samples) { if (!time || time < beginAt || time > endAt) continue; let blocks = []; for (let { category: bitmask } of frames) { if (!bitmask) continue; let category = CATEGORY_MAPPINGS[bitmask]; // Guard against categories that aren't found in the frontend mappings. // This usually means that a new category was added in the platform, // but browser/devtools/profiler/utils/global.js wasn't updated yet. if (!category) { category = CATEGORY_MAPPINGS[CATEGORY_OTHER]; } if (!blocks[category.ordinal]) { blocks[category.ordinal] = 1; } else { blocks[category.ordinal]++; } } // If no categories were found in the frames, default to using a // single block using the stack depth as height. if (blocks.length == 0) { blocks[CATEGORY_MAPPINGS[CATEGORY_OTHER].ordinal] = frames.length; } categoriesData.push({ delta: time, values: blocks }); } return categoriesData; }, /** * Creates an appropriate data source to be displayed in a framerate graph * from on the provided refresh driver ticks data. * * @param object ticksData * The refresh driver ticks received from the front. * @param number beginAt * The earliest time in the ticks data to start at (in milliseconds). * @param number endAt * The latest time in the ticks data to end at (in milliseconds). * @return array * A data source useful for a LineGraphWidget. */ plotFramerateFor: function(ticksData, beginAt, endAt) { // Older Gecko versions don't have a framerate actor implementation, // in which case the returned ticks data is null. if (ticksData == null) { return []; } let framerateData = this._frameratePlotsCache.get(ticksData); if (framerateData == null) { framerateData = FramerateFront.plotFPS(ticksData, FRAMERATE_CALC_INTERVAL); this._frameratePlotsCache.set(ticksData, framerateData); } // Quickly find the earliest and oldest valid index in the plotted // framerate data based on the specified beginAt and endAt time. Sure, // using [].findIndex would be more elegant, but also slower. let earliestValidIndex = findFirstIndex(framerateData, e => e.delta >= beginAt); let oldestValidIndex = findLastIndex(framerateData, e => e.delta <= endAt); let totalValues = framerateData.length; // If all the plotted framerate data fits inside the specified time range, // simply return it. if (earliestValidIndex == 0 && oldestValidIndex == totalValues - 1) { return framerateData; } // Otherwise, a slice will need to be made. Be very careful here, the // beginAt and endAt timestamps can refer to a point in *between* two // entries in the framerate data, so we'll need to insert new values where // the cuts are made. let slicedData = framerateData.slice(earliestValidIndex, oldestValidIndex + 1); if (earliestValidIndex > 0) { slicedData.unshift({ delta: beginAt, value: framerateData[earliestValidIndex - 1].value }); } if (oldestValidIndex < totalValues - 1) { slicedData.push({ delta: endAt, value: framerateData[oldestValidIndex + 1].value }); } return slicedData; }, /** * Makes sure the data sources for the categories and framerate graphs * have the same beginning and ending, time-wise. * * @param array categoriesData * Data source generated by `RecordingUtils.plotCategoriesFor`. * @param array framerateData * Data source generated by `RecordingUtils.plotFramerateFor`. */ syncCategoriesWithFramerate: function(categoriesData, framerateData) { if (categoriesData.length < 2 || framerateData.length < 2) { return; } let categoryBegin = categoriesData[0]; let categoryEnd = categoriesData[categoriesData.length - 1]; let framerateBegin = framerateData[0]; let framerateEnd = framerateData[framerateData.length - 1]; if (categoryBegin.delta > framerateBegin.delta) { categoriesData.unshift({ delta: framerateBegin.delta, values: categoryBegin.values }); } else { framerateData.unshift({ delta: categoryBegin.delta, value: framerateBegin.value }); } if (categoryEnd.delta < framerateEnd.delta) { categoriesData.push({ delta: framerateEnd.delta, values: categoryEnd.values }); } else { framerateData.push({ delta: categoryEnd.delta, value: framerateEnd.value }); } } }; /** * Finds the index of the first element in an array that validates a predicate. * @param array * @param function predicate * @return number */ function findFirstIndex(array, predicate) { for (let i = 0, len = array.length; i < len; i++) { if (predicate(array[i])) return i; } } /** * Finds the last of the first element in an array that validates a predicate. * @param array * @param function predicate * @return number */ function findLastIndex(array, predicate) { for (let i = array.length - 1; i >= 0; i--) { if (predicate(array[i])) return i; } } /** * Opens/selects the debugger in this toolbox and jumps to the specified * file name and line number. * @param string url * @param number line */ function viewSourceInDebugger(url, line) { let showSource = ({ DebuggerView }) => { if (DebuggerView.Sources.containsValue(url)) { DebuggerView.setEditorLocation(url, line, { noDebug: true }).then(() => { window.emit(EVENTS.SOURCE_SHOWN_IN_JS_DEBUGGER); }, () => { window.emit(EVENTS.SOURCE_NOT_FOUND_IN_JS_DEBUGGER); }); } }; // If the Debugger was already open, switch to it and try to show the // source immediately. Otherwise, initialize it and wait for the sources // to be added first. let debuggerAlreadyOpen = gToolbox.getPanel("jsdebugger"); gToolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => { if (debuggerAlreadyOpen) { showSource(dbg); } else { dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg)); } }); }