/* 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"; const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/devtools/Loader.jsm"); Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); Cu.import("resource:///modules/devtools/gDevTools.jsm"); devtools.lazyRequireGetter(this, "promise"); devtools.lazyRequireGetter(this, "EventEmitter", "devtools/toolkit/event-emitter"); devtools.lazyRequireGetter(this, "MarkersOverview", "devtools/shared/timeline/markers-overview", true); devtools.lazyRequireGetter(this, "MemoryOverview", "devtools/shared/timeline/memory-overview", true); devtools.lazyRequireGetter(this, "Waterfall", "devtools/shared/timeline/waterfall", true); devtools.lazyRequireGetter(this, "MarkerDetails", "devtools/shared/timeline/marker-details", true); devtools.lazyRequireGetter(this, "TIMELINE_BLUEPRINT", "devtools/shared/timeline/global", true); devtools.lazyImporter(this, "CanvasGraphUtils", "resource:///modules/devtools/Graphs.jsm"); devtools.lazyImporter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); const OVERVIEW_UPDATE_INTERVAL = 200; const OVERVIEW_INITIAL_SELECTION_RATIO = 0.15; /** * Preference for devtools.timeline.hiddenMarkers. * Stores which markers should be hidden. */ const Prefs = new ViewHelpers.Prefs("devtools.timeline", { hiddenMarkers: ["Json", "hiddenMarkers"] }); // The panel's window global is an EventEmitter firing the following events: const EVENTS = { // When a recording is started or stopped, via the `stopwatch` button. RECORDING_STARTED: "Timeline:RecordingStarted", RECORDING_ENDED: "Timeline:RecordingEnded", // When the overview graphs are populated with new markers. OVERVIEW_UPDATED: "Timeline:OverviewUpdated", // When the waterfall view is populated with new markers. WATERFALL_UPDATED: "Timeline:WaterfallUpdated" }; /** * The current target and the timeline front, set by this tool's host. */ let gToolbox, gTarget, gFront; /** * Initializes the timeline controller and views. */ let startupTimeline = Task.async(function*() { yield TimelineView.initialize(); yield TimelineController.initialize(); }); /** * Destroys the timeline controller and views. */ let shutdownTimeline = Task.async(function*() { yield TimelineView.destroy(); yield TimelineController.destroy(); yield gFront.stop(); }); /** * Functions handling the timeline frontend controller. */ let TimelineController = { /** * Permanent storage for the markers and the memory measurements streamed by * the backend, along with the start and end timestamps. */ _starTime: 0, _endTime: 0, _markers: [], _memory: [], _frames: [], /** * Initialization function, called when the tool is started. */ initialize: function() { this._onRecordingTick = this._onRecordingTick.bind(this); this._onMarkers = this._onMarkers.bind(this); this._onMemory = this._onMemory.bind(this); this._onFrames = this._onFrames.bind(this); gFront.on("markers", this._onMarkers); gFront.on("memory", this._onMemory); gFront.on("frames", this._onFrames); }, /** * Destruction function, called when the tool is closed. */ destroy: function() { gFront.off("markers", this._onMarkers); gFront.off("memory", this._onMemory); gFront.off("frames", this._onFrames); }, /** * Gets the { stat, end } time interval for this recording. * @return object */ getInterval: function() { return { startTime: this._startTime, endTime: this._endTime }; }, /** * Gets the accumulated markers in this recording. * @return array */ getMarkers: function() { return this._markers; }, /** * Gets the accumulated memory measurements in this recording. * @return array */ getMemory: function() { return this._memory; }, /** * Gets stack frame array reported by the actor. The marker "stack" * and "endStack" properties are indices into this array. See * actors/utils/stack.js for more details. * @return array */ getFrames: function() { return this._frames; }, /** * Updates the views to show or hide the memory recording data. */ updateMemoryRecording: Task.async(function*() { if ($("#memory-checkbox").checked) { yield TimelineView.showMemoryOverview(); } else { yield TimelineView.hideMemoryOverview(); } }), /** * Starts/stops the timeline recording and streaming. */ toggleRecording: Task.async(function*() { let isRecording = yield gFront.isRecording(); if (isRecording == false) { yield this._startRecording(); } else { yield this._stopRecording(); } }), /** * Starts the recording, updating the UI as needed. */ _startRecording: function*() { TimelineView.handleRecordingStarted(); let withMemory = $("#memory-checkbox").checked; let startTime = yield gFront.start({ withMemory }); // Times must come from the actor in order to be self-consistent. // However, we also want to update the view with the elapsed time // even when the actor is not generating data. To do this we get // the local time and use it to compute a reasonable elapsed time. // See _onRecordingTick. this._localStartTime = performance.now(); this._startTime = startTime; this._endTime = startTime; this._markers = []; this._memory = []; this._frames = []; this._updateId = setInterval(this._onRecordingTick, OVERVIEW_UPDATE_INTERVAL); }, /** * Stops the recording, updating the UI as needed. */ _stopRecording: function*() { clearInterval(this._updateId); // Sorting markers is only important when displayed in the waterfall. this._markers = this._markers.sort((a,b) => (a.start > b.start)); TimelineView.handleRecordingUpdate(); TimelineView.handleRecordingEnded(); yield gFront.stop(); }, /** * Used in tests. Stops the recording, discarding the accumulated markers and * updating the UI as needed. */ _stopRecordingAndDiscardData: function*() { // Clear the markers before calling async method _stopRecording to properly // reset the selection if markers were already received. Bug 1092452. this._markers.length = 0; this._memory.length = 0; yield this._stopRecording(); // Clear the markers after _stopRecording has finished. It's possible that // server sent new markers before it received the request to stop sending // them and client received them while we were waiting for _stopRecording // to finish. Bug 1067287. this._markers.length = 0; this._memory.length = 0; }, /** * Callback handling the "markers" event on the timeline front. * * @param array markers * A list of new markers collected since the last time this * function was invoked. * @param number endTime * A time after the last marker in markers was collected. */ _onMarkers: function(markers, endTime) { for (let marker of markers) { marker.start -= this._startTime; marker.end -= this._startTime; } Array.prototype.push.apply(this._markers, markers); this._endTime = endTime; }, /** * Callback handling the "memory" event on the timeline front. * * @param number delta * The number of milliseconds elapsed since epoch. * @param object measurement * A detailed breakdown of the current memory usage. */ _onMemory: function(delta, measurement) { this._memory.push({ delta: delta - this._startTime, value: measurement.total / 1024 / 1024 }); }, /** * Callback handling the "frames" event on the timeline front. * * @param number delta * The number of milliseconds elapsed since epoch. * @param object frames * Newly generated frame objects. */ _onFrames: function(delta, frames) { Array.prototype.push.apply(this._frames, frames); }, /** * Callback invoked at a fixed interval while recording. * Updates the current time and the timeline overview. */ _onRecordingTick: function() { // Compute an approximate ending time for the view. This is // needed to ensure that the view updates even when new data is // not being generated. let fakeTime = this._startTime + (performance.now() - this._localStartTime); if (fakeTime > this._endTime) { this._endTime = fakeTime; } TimelineView.handleRecordingUpdate(); } }; /** * Functions handling the timeline frontend view. */ let TimelineView = { /** * Initialization function, called when the tool is started. */ initialize: Task.async(function*() { let blueprint = this._getFilteredBluePrint(); this.markersOverview = new MarkersOverview($("#markers-overview"), blueprint); this.waterfall = new Waterfall($("#timeline-waterfall"), $("#timeline-pane"), blueprint); this.markerDetails = new MarkerDetails($("#timeline-waterfall-details"), $("#timeline-waterfall-container > splitter")); this._onThemeChange = this._onThemeChange.bind(this); this._onSelecting = this._onSelecting.bind(this); this._onRefresh = this._onRefresh.bind(this); gDevTools.on("pref-changed", this._onThemeChange); this.markersOverview.on("selecting", this._onSelecting); this.markersOverview.on("refresh", this._onRefresh); this.markerDetails.on("resize", this._onRefresh); this._onMarkerSelected = this._onMarkerSelected.bind(this); this.waterfall.on("selected", this._onMarkerSelected); this.waterfall.on("unselected", this._onMarkerSelected); let theme = Services.prefs.getCharPref("devtools.theme"); this.markersOverview.setTheme(theme); yield this.markersOverview.ready(); yield this.waterfall.recalculateBounds(); this._buildFilterPopup(); }), /** * Destruction function, called when the tool is closed. */ destroy: function() { gDevTools.off("pref-changed", this._onThemeChange); this.markerDetails.off("resize", this._onRefresh); this.markerDetails.destroy(); this.waterfall.off("selected", this._onMarkerSelected); this.waterfall.off("unselected", this._onMarkerSelected); this.waterfall.destroy(); this.markersOverview.off("selecting", this._onSelecting); this.markersOverview.off("refresh", this._onRefresh); this.markersOverview.destroy(); // The memory overview graph is not always available. if (this.memoryOverview) { this.memoryOverview.destroy(); } }, /** * Shows the memory overview graph. */ showMemoryOverview: Task.async(function*() { let theme = Services.prefs.getCharPref("devtools.theme"); this.memoryOverview = new MemoryOverview($("#memory-overview")); this.memoryOverview.setTheme(theme); yield this.memoryOverview.ready(); let memory = TimelineController.getMemory(); this.memoryOverview.setData(memory); CanvasGraphUtils.linkAnimation(this.markersOverview, this.memoryOverview); CanvasGraphUtils.linkSelection(this.markersOverview, this.memoryOverview); }), /** * Hides the memory overview graph. */ hideMemoryOverview: function() { if (!this.memoryOverview) { return; } this.memoryOverview.destroy(); this.memoryOverview = null; }, /** * A marker has been selected in the waterfall. */ _onMarkerSelected: function(event, marker) { if (event == "selected") { this.markerDetails.render({ toolbox: gToolbox, marker: marker, frames: TimelineController.getFrames() }); } if (event == "unselected") { this.markerDetails.empty(); } }, /** * Signals that a recording session has started and triggers the appropriate * changes in the UI. */ handleRecordingStarted: function() { $("#record-button").setAttribute("checked", "true"); $("#memory-checkbox").setAttribute("disabled", "true"); $("#timeline-pane").selectedPanel = $("#recording-notice"); this.markersOverview.clearView(); // The memory overview graph is not always available. if (this.memoryOverview) { this.memoryOverview.clearView(); } this.waterfall.clearView(); window.emit(EVENTS.RECORDING_STARTED); }, /** * Signals that a recording session has ended and triggers the appropriate * changes in the UI. */ handleRecordingEnded: function() { $("#record-button").removeAttribute("checked"); $("#memory-checkbox").removeAttribute("disabled"); $("#timeline-pane").selectedPanel = $("#timeline-waterfall-container"); this.markersOverview.selectionEnabled = true; // The memory overview graph is not always available. if (this.memoryOverview) { this.memoryOverview.selectionEnabled = true; } let interval = TimelineController.getInterval(); let markers = TimelineController.getMarkers(); let memory = TimelineController.getMemory(); if (markers.length) { let start = markers[0].start * this.markersOverview.dataScaleX; let end = start + this.markersOverview.width * OVERVIEW_INITIAL_SELECTION_RATIO; this.markersOverview.setSelection({ start, end }); } else { let startTime = interval.startTime; let endTime = interval.endTime; this.waterfall.setData({ markers, interval: { startTime, endTime } }); } window.emit(EVENTS.RECORDING_ENDED); }, /** * Signals that a new set of markers was made available by the controller, * or that the overview graph needs to be updated. */ handleRecordingUpdate: function() { let interval = TimelineController.getInterval(); let markers = TimelineController.getMarkers(); let memory = TimelineController.getMemory(); let duration = interval.endTime - interval.startTime; this.markersOverview.setData({ markers, duration }); // The memory overview graph is not always available. if (this.memoryOverview) { this.memoryOverview.setData(memory); } window.emit(EVENTS.OVERVIEW_UPDATED); }, /** * Callback handling the "selecting" event on the timeline overview. */ _onSelecting: function() { if (!this.markersOverview.hasSelection() && !this.markersOverview.hasSelectionInProgress()) { this.waterfall.clearView(); return; } this.waterfall.resetSelection(); this.updateWaterfall(); }, /** * Rebuild the waterfall. */ updateWaterfall: function() { let selection = this.markersOverview.getSelection(); let start = selection.start / this.markersOverview.dataScaleX; let end = selection.end / this.markersOverview.dataScaleX; let markers = TimelineController.getMarkers(); let interval = TimelineController.getInterval(); let startTime = Math.min(start, end); let endTime = Math.max(start, end); this.waterfall.setData({ markers, interval: { startTime, endTime } }); }, /** * Callback handling the "refresh" event on the timeline overview. */ _onRefresh: function() { this.waterfall.recalculateBounds(); this.updateWaterfall(); }, /** * Rebuild a blueprint without hidden markers. */ _getFilteredBluePrint: function() { let hiddenMarkers = Prefs.hiddenMarkers; let filteredBlueprint = Cu.cloneInto(TIMELINE_BLUEPRINT, {}); let maybeRemovedGroups = new Set(); let removedGroups = new Set(); // 1. Remove hidden markers from the blueprint. for (let hiddenMarkerName of hiddenMarkers) { maybeRemovedGroups.add(filteredBlueprint[hiddenMarkerName].group); delete filteredBlueprint[hiddenMarkerName]; } // 2. Get a list of all the groups that will be removed. for (let removedGroup of maybeRemovedGroups) { let markerNames = Object.keys(filteredBlueprint); let allGroupsRemoved = markerNames.every(e => filteredBlueprint[e].group != removedGroup); if (allGroupsRemoved) { removedGroups.add(removedGroup); } } // 3. Offset groups. for (let removedGroup of removedGroups) { for (let [, markerDetails] of Iterator(filteredBlueprint)) { if (markerDetails.group > removedGroup) { markerDetails.group--; } } } return filteredBlueprint; }, /** * When the list of hidden markers changes, update waterfall * and overview. */ _onHiddenMarkersChanged: function(e) { let menuItems = $$("#timelineFilterPopup menuitem[marker-type]:not([checked])"); let hiddenMarkers = Array.map(menuItems, e => e.getAttribute("marker-type")); Prefs.hiddenMarkers = hiddenMarkers; let blueprint = this._getFilteredBluePrint(); this.waterfall.setBlueprint(blueprint); this.updateWaterfall(); this.markersOverview.setBlueprint(blueprint); this.markersOverview.refresh({ force: true }); }, /** * Creates the filter popup. */ _buildFilterPopup: function() { let popup = $("#timelineFilterPopup"); let button = $("#filter-button"); popup.addEventListener("popupshowing", () => button.setAttribute("open", "true")); popup.addEventListener("popuphiding", () => button.removeAttribute("open")); this._onHiddenMarkersChanged = this._onHiddenMarkersChanged.bind(this); for (let [markerName, markerDetails] of Iterator(TIMELINE_BLUEPRINT)) { let menuitem = document.createElement("menuitem"); menuitem.setAttribute("closemenu", "none"); menuitem.setAttribute("type", "checkbox"); menuitem.setAttribute("marker-type", markerName); menuitem.setAttribute("label", markerDetails.label); menuitem.setAttribute("flex", "1"); menuitem.setAttribute("align", "center"); menuitem.addEventListener("command", this._onHiddenMarkersChanged); if (Prefs.hiddenMarkers.indexOf(markerName) == -1) { menuitem.setAttribute("checked", "true"); } // Style used by pseudo element ::before in timeline.css.in let bulletStyle = `--bullet-bg: ${markerDetails.fill};` bulletStyle += `--bullet-border: ${markerDetails.stroke}`; menuitem.setAttribute("style", bulletStyle); popup.appendChild(menuitem); } }, /* * Called when the developer tools theme changes. Redraws * the graphs with the new theme setting. */ _onThemeChange: function (_, theme) { if (this.memoryOverview) { this.memoryOverview.setTheme(theme.newValue); this.memoryOverview.refresh({ force: true }); } this.markersOverview.setTheme(theme.newValue); this.markersOverview.refresh({ force: true }); } }; /** * Convenient way of emitting events from the panel window. */ EventEmitter.decorate(this); /** * DOM query helpers. */ function $(selector, target = document) { return target.querySelector(selector); } function $$(selector, target = document) { return target.querySelectorAll(selector); }