Bug 1077458 - Implement marker's waterfall view in new performance tool, with details view toggling between waterfall and call tree views. r=vp

This commit is contained in:
Jordan Santell 2014-12-03 16:36:00 +01:00
parent 56e90833e7
commit cc83eaf9c7
16 changed files with 521 additions and 35 deletions

View File

@ -93,6 +93,7 @@ browser.jar:
content/browser/devtools/performance/views/overview.js (performance/views/overview.js)
content/browser/devtools/performance/views/details.js (performance/views/details.js)
content/browser/devtools/performance/views/call-tree.js (performance/views/call-tree.js)
content/browser/devtools/performance/views/waterfall.js (performance/views/waterfall.js)
#endif
content/browser/devtools/responsivedesign/resize-commands.js (responsivedesign/resize-commands.js)
content/browser/devtools/commandline.css (commandline/commandline.css)

View File

@ -23,6 +23,10 @@ devtools.lazyRequireGetter(this, "L10N",
"devtools/profiler/global", true);
devtools.lazyImporter(this, "LineGraphWidget",
"resource:///modules/devtools/Graphs.jsm");
devtools.lazyRequireGetter(this, "Waterfall",
"devtools/timeline/waterfall", true);
devtools.lazyRequireGetter(this, "MarkerDetails",
"devtools/timeline/marker-details", true);
devtools.lazyRequireGetter(this, "CallView",
"devtools/profiler/tree-view", true);
devtools.lazyRequireGetter(this, "ThreadNode",
@ -47,8 +51,14 @@ const EVENTS = {
// Emitted by the OverviewView when a selection range has been removed
OVERVIEW_RANGE_CLEARED: "Performance:UI:OverviewRangeCleared",
// Emitted by the DetailsView when a subview is selected
DETAILS_VIEW_SELECTED: "Performance:UI:DetailsViewSelected",
// Emitted by the CallTreeView when a call tree has been rendered
CALL_TREE_RENDERED: "Performance:UI:CallTreeRendered"
CALL_TREE_RENDERED: "Performance:UI:CallTreeRendered",
// Emitted by the WaterfallView when it has been rendered
WATERFALL_RENDERED: "Performance:UI:WaterfallRendered"
};
/**
@ -112,6 +122,7 @@ let PerformanceController = {
PerformanceView.on(EVENTS.UI_START_RECORDING, this.startRecording);
PerformanceView.on(EVENTS.UI_STOP_RECORDING, this.stopRecording);
gFront.on("ticks", this._onTimelineData);
gFront.on("markers", this._onTimelineData);
},
/**
@ -120,6 +131,8 @@ let PerformanceController = {
destroy: function() {
PerformanceView.off(EVENTS.UI_START_RECORDING, this.startRecording);
PerformanceView.off(EVENTS.UI_STOP_RECORDING, this.stopRecording);
gFront.off("ticks", this._onTimelineData);
gFront.off("markers", this._onTimelineData);
},
/**
@ -127,8 +140,12 @@ let PerformanceController = {
* when the front is starting to record.
*/
startRecording: Task.async(function *() {
yield gFront.startRecording();
this.emit(EVENTS.RECORDING_STARTED);
// Save local start time for use with faking the endTime
// if not returned from the timeline actor
this._localStartTime = performance.now();
let startTime = this._startTime = yield gFront.startRecording();
this.emit(EVENTS.RECORDING_STARTED, startTime);
}),
/**
@ -137,6 +154,12 @@ let PerformanceController = {
*/
stopRecording: Task.async(function *() {
let results = yield gFront.stopRecording();
// If `endTime` is not yielded from timeline actor (< Fx36),
// fake an endTime
if (!results.endTime) {
this._endTime = results.endTime = this._startTime + (performance.now() - this._localStartTime);
}
this.emit(EVENTS.RECORDING_STOPPED, results);
}),

View File

@ -237,7 +237,12 @@ PerformanceFront.prototype = {
// The timeline actor is target-dependent, so just make sure
// it's recording.
let withMemory = showTimelineMemory();
yield this._request("timeline", "start", { withTicks: true, withMemory: withMemory });
// Return start time from timeline actor
let startTime = yield this._request("timeline", "start", { withTicks: true, withMemory: withMemory });
this._startTime = startTime;
return { startTime };
}),
/**
@ -254,12 +259,13 @@ PerformanceFront.prototype = {
filterSamples(profilerData, this._profilingStartTime);
offsetSampleTimes(profilerData, this._profilingStartTime);
yield this._request("timeline", "stop");
let endTime = this._endTime = yield this._request("timeline", "stop");
// Join all the acquired data and return it for outside consumers.
return {
recordingDuration: profilerData.currentTime - this._profilingStartTime,
profilerData: profilerData
profilerData: profilerData,
endTime: endTime
};
}),

View File

@ -19,6 +19,7 @@
<script type="application/javascript" src="performance/views/overview.js"/>
<script type="application/javascript" src="performance/views/details.js"/>
<script type="application/javascript" src="performance/views/call-tree.js"/>
<script type="application/javascript" src="performance/views/waterfall.js"/>
<vbox class="theme-body" flex="1">
<toolbar id="performance-toolbar" class="devtools-toolbar">
@ -44,10 +45,28 @@
<vbox id="time-framerate" flex="1"/>
</box>
<splitter class="devtools-horizontal-splitter" />
<box id="details-pane"
<toolbar id="details-toolbar" class="devtools-toolbar">
<hbox class="devtools-toolbarbutton-group">
<toolbarbutton id="select-waterfall-view"
class="devtools-toolbarbutton"
tooltiptext="waterfall"
data-view="waterfall" />
<toolbarbutton id="select-calltree-view"
class="devtools-toolbarbutton"
tooltiptext="calltree"
data-view="calltree" />
</hbox>
</toolbar>
<deck id="details-pane"
class="devtools-responsive-container"
flex="1">
<vbox class="call-tree" flex="1">
<hbox id="waterfall-view" flex="1">
<vbox id="waterfall-graph" flex="1" />
<splitter class="devtools-side-splitter"/>
<vbox id="waterfall-details" class="theme-sidebar" width="150" height="150"/>
</hbox>
<vbox id="calltree-view" class="call-tree" flex="1">
<hbox class="call-tree-headers-container">
<label class="plain call-tree-header"
type="duration"
@ -76,6 +95,6 @@
</hbox>
<vbox class="call-tree-cells-container" flex="1"/>
</vbox>
</box>
</deck>
</vbox>
</window>

View File

@ -9,6 +9,7 @@ support-files =
# that need to be moved over to performance tool
[browser_perf-aaa-run-first-leaktest.js]
[browser_perf-front.js]
[browser_perf-front-basic-timeline-01.js]
[browser_perf-front-basic-profiler-01.js]
# bug 1077464
@ -32,5 +33,8 @@ support-files =
[browser_perf-overview-render-01.js]
[browser_perf-overview-render-02.js]
[browser_perf-overview-selection.js]
[browser_perf-details.js]
[browser_perf-details-calltree-render-01.js]
[browser_perf-details-calltree-render-02.js]
[browser_perf-details-waterfall-render-01.js]

View File

@ -0,0 +1,26 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Tests that the waterfall view renders content after recording.
*/
function spawnTest () {
let { panel } = yield initPerformance(SIMPLE_URL);
let { EVENTS, WaterfallView } = panel.panelWin;
yield startRecording(panel);
yield waitUntil(() => WaterfallView._markers.length);
let rendered = once(WaterfallView, EVENTS.WATERFALL_RENDERED);
yield stopRecording(panel);
yield rendered;
ok(true, "WaterfallView rendered after recording is stopped.");
ok(WaterfallView._markers.length, "WaterfallView contains markers");
yield teardown(panel);
finish();
}

View File

@ -0,0 +1,46 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Tests that the details view toggles subviews.
*/
function spawnTest () {
let { panel } = yield initPerformance(SIMPLE_URL);
let { EVENTS, $, DetailsView, document: doc } = panel.panelWin;
info("views on startup");
checkViews(DetailsView, doc, "waterfall");
// Select calltree view
let viewChanged = onceSpread(DetailsView, EVENTS.DETAILS_VIEW_SELECTED);
command($("toolbarbutton[data-view='calltree']"));
let [_, viewName] = yield viewChanged;
is(viewName, "calltree", "DETAILS_VIEW_SELECTED fired with view name");
checkViews(DetailsView, doc, "calltree");
// Select waterfall view
viewChanged = onceSpread(DetailsView, EVENTS.DETAILS_VIEW_SELECTED);
command($("toolbarbutton[data-view='waterfall']"));
[_, viewName] = yield viewChanged;
is(viewName, "waterfall", "DETAILS_VIEW_SELECTED fired with view name");
checkViews(DetailsView, doc, "waterfall");
yield teardown(panel);
finish();
}
function checkViews (DetailsView, doc, currentView) {
for (let viewName in DetailsView.views) {
let view = DetailsView.views[viewName].el;
let button = doc.querySelector("toolbarbutton[data-view='" + viewName + "']");
if (viewName === currentView) {
ok(!view.getAttribute("hidden"), view + " view displayed");
ok(button.getAttribute("checked"), view + " button checked");
} else {
ok(view.getAttribute("hidden"), view + " view hidden");
ok(!button.getAttribute("checked"), view + " button not checked");
}
}
}

View File

@ -0,0 +1,27 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
/**
* Test basic functionality of PerformanceFront, emitting start and endtime values
*/
let WAIT = 1000;
function spawnTest () {
let { target, front } = yield initBackend(SIMPLE_URL);
let { startTime } = yield front.startRecording();
ok(typeof startTime === "number", "front.startRecording() emits start time");
yield busyWait(WAIT);
let { endTime } = yield front.stopRecording();
ok(typeof endTime === "number", "front.stopRecording() emits end time");
ok(endTime > startTime, "endTime is after startTime");
yield removeTab(target.tab);
finish();
}

View File

@ -211,8 +211,14 @@ function busyWait(time) {
while (Date.now() - start < time) { stack = Components.stack; }
}
function idleWait(time) {
return DevToolsUtils.waitForTime(time);
function command (button) {
let ev = button.ownerDocument.createEvent("XULCommandEvent");
ev.initCommandEvent("command", true, true, button.ownerDocument.defaultView, 0, false, false, false, false, null);
button.dispatchEvent(ev);
}
function click (win, button) {
EventUtils.sendMouseEvent({ type: "click" }, button, win);
}
function* startRecording(panel) {
@ -227,7 +233,7 @@ function* startRecording(panel) {
ok(!button.hasAttribute("locked"),
"The record button should not be locked yet.");
EventUtils.sendMouseEvent({ type: "click" }, button, win);
click(win, button);
yield clicked;
@ -255,7 +261,7 @@ function* stopRecording(panel) {
ok(!button.hasAttribute("locked"),
"The record button should not be locked yet.");
EventUtils.sendMouseEvent({ type: "click" }, button, win);
click(win, button);
yield clicked;

View File

@ -3,34 +3,69 @@
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const DEFAULT_DETAILS_SUBVIEW = "waterfall";
/**
* Details view containing profiler call tree. Manages
* subviews and toggles visibility between them.
*/
let DetailsView = {
/**
* Name to index mapping of subviews, used by selecting view.
*/
viewIndexes: {
waterfall: 0,
calltree: 1
},
/**
* Sets up the view with event binding, initializes
* subviews.
*/
initialize: function () {
this.views = {
callTree: CallTreeView
};
initialize: Task.async(function *() {
this.el = $("#details-pane");
// Initialize subviews
return promise.all([
CallTreeView.initialize()
]);
this._onViewToggle = this._onViewToggle.bind(this);
for (let button of $$("toolbarbutton[data-view]", $("#details-toolbar"))) {
button.addEventListener("command", this._onViewToggle);
}
yield CallTreeView.initialize();
yield WaterfallView.initialize();
this.selectView(DEFAULT_DETAILS_SUBVIEW);
}),
/**
* Select one of the DetailView's subviews to be rendered,
* hiding the others.
*
* @params {String} selectedView
* Name of the view to be shown.
*/
selectView: function (selectedView) {
this.el.selectedIndex = this.viewIndexes[selectedView];
this.emit(EVENTS.DETAILS_VIEW_SELECTED, selectedView);
},
/**
* Called when a view button is clicked.
*/
_onViewToggle: function (e) {
this.selectView(e.target.getAttribute("data-view"));
},
/**
* Unbinds events, destroys subviews.
*/
destroy: function () {
return promise.all([
CallTreeView.destroy()
]);
}
destroy: Task.async(function *() {
for (let button of $$("toolbarbutton[data-view]", $("#details-toolbar"))) {
button.removeEventListener("command", this._onViewToggle);
}
yield CallTreeView.destroy();
yield WaterfallView.destroy();
})
};
/**

View File

@ -0,0 +1,116 @@
/* 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";
/**
* Waterfall view controlled by DetailsView.
*/
let WaterfallView = {
_startTime: 0,
_endTime: 0,
_markers: [],
/**
* Sets up the view with event binding.
*/
initialize: Task.async(function *() {
this.el = $("#waterfall-view");
this._stop = this._stop.bind(this);
this._start = this._start.bind(this);
this._onTimelineData = this._onTimelineData.bind(this);
this._onMarkerSelected = this._onMarkerSelected.bind(this);
this._onResize = this._onResize.bind(this);
this.graph = new Waterfall($("#waterfall-graph"), $("#details-pane"));
this.markerDetails = new MarkerDetails($("#waterfall-details"), $("#waterfall-view > splitter"));
this.graph.on("selected", this._onMarkerSelected);
this.graph.on("unselected", this._onMarkerSelected);
this.markerDetails.on("resize", this._onResize);
PerformanceController.on(EVENTS.RECORDING_STARTED, this._start);
PerformanceController.on(EVENTS.RECORDING_STOPPED, this._stop);
PerformanceController.on(EVENTS.TIMELINE_DATA, this._onTimelineData);
yield this.graph.recalculateBounds();
}),
/**
* Unbinds events.
*/
destroy: function () {
this.graph.off("selected", this._onMarkerSelected);
this.graph.off("unselected", this._onMarkerSelected);
this.markerDetails.off("resize", this._onResize);
PerformanceController.off(EVENTS.RECORDING_STARTED, this._start);
PerformanceController.off(EVENTS.RECORDING_STOPPED, this._stop);
PerformanceController.off(EVENTS.TIMELINE_DATA, this._onTimelineData);
},
render: Task.async(function *() {
yield this.graph.recalculateBounds();
this.graph.setData(this._markers, this._startTime, this._startTime, this._endTime);
this.emit(EVENTS.WATERFALL_RENDERED);
}),
/**
* Event handlers
*/
/**
* Called when recording starts.
*/
_start: function (_, { startTime }) {
this._startTime = startTime;
this._endTime = startTime;
this.graph.clearView();
},
/**
* Called when recording stops.
*/
_stop: Task.async(function *(_, { endTime }) {
this._endTime = endTime;
this._markers = this._markers.sort((a,b) => (a.start > b.start));
this.render();
}),
/**
* Called when a marker is selected in the waterfall view,
* updating the markers detail view.
*/
_onMarkerSelected: function (event, marker) {
if (event === "selected") {
this.markerDetails.render(marker);
}
if (event === "unselected") {
this.markerDetails.empty();
}
},
/**
* Called when the marker details view is resized.
*/
_onResize: function () {
this.render();
},
/**
* Called when the TimelineFront has new data for
* framerate, markers or memory, and stores the data
* to be plotted subsequently.
*/
_onTimelineData: function (_, eventName, ...data) {
if (eventName === "markers") {
let [markers, endTime] = data;
Array.prototype.push.apply(this._markers, markers);
}
}
};
/**
* Convenient way of emitting events from the view.
*/
EventEmitter.decorate(WaterfallView);

View File

@ -251,8 +251,8 @@ let TimelineView = {
*/
initialize: Task.async(function*() {
this.markersOverview = new MarkersOverview($("#markers-overview"));
this.waterfall = new Waterfall($("#timeline-waterfall"));
this.markerDetails = new MarkerDetails($("#timeline-waterfall-details"));
this.waterfall = new Waterfall($("#timeline-waterfall"), $("#timeline-pane"));
this.markerDetails = new MarkerDetails($("#timeline-waterfall-details"), $("#timeline-waterfall-container > splitter"));
this._onSelecting = this._onSelecting.bind(this);
this._onRefresh = this._onRefresh.bind(this);
@ -273,8 +273,10 @@ let TimelineView = {
*/
destroy: function() {
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();

View File

@ -22,12 +22,14 @@ loader.lazyRequireGetter(this, "EventEmitter",
*
* @param nsIDOMNode parent
* The parent node holding the view.
* @param nsIDOMNode splitter
* The splitter node that the resize event is bound to.
*/
function MarkerDetails(parent) {
function MarkerDetails(parent, splitter) {
EventEmitter.decorate(this);
this._document = parent.ownerDocument;
this._parent = parent;
this._splitter = this._document.querySelector("#timeline-waterfall-container > splitter");
this._splitter = splitter;
this._splitter.addEventListener("mouseup", () => this.emit("resize"));
}
@ -35,6 +37,7 @@ MarkerDetails.prototype = {
destroy: function() {
this.empty();
this._parent = null;
this._splitter = null;
},
/**

View File

@ -48,11 +48,14 @@ const WATERFALL_ROWCOUNT_ONPAGEUPDOWN = 10;
*
* @param nsIDOMNode parent
* The parent node holding the waterfall.
* @param nsIDOMNode container
* The container node that key events should be bound to.
*/
function Waterfall(parent) {
function Waterfall(parent, container) {
EventEmitter.decorate(this);
this._parent = parent;
this._document = parent.ownerDocument;
this._container = container;
this._fragment = this._document.createDocumentFragment();
this._outstandingMarkers = [];
@ -78,9 +81,15 @@ function Waterfall(parent) {
// Selected row index. By default, we want the first
// row to be selected.
this._selectedRowIdx = 0;
// Default rowCount
this.rowCount = WATERFALL_ROWCOUNT_ONPAGEUPDOWN;
}
Waterfall.prototype = {
destroy: function() {
this._parent = this._document = this._container = null;
},
/**
* Populates this view with the provided data source.
*
@ -110,7 +119,7 @@ Waterfall.prototype = {
* Keybindings.
*/
setupKeys: function() {
let pane = this._document.querySelector("#timeline-pane");
let pane = this._container;
pane.parentNode.parentNode.addEventListener("keydown", e => {
if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_UP) {
e.preventDefault();
@ -130,11 +139,11 @@ Waterfall.prototype = {
}
if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_UP) {
e.preventDefault();
this.selectNearestRow(this._selectedRowIdx - WATERFALL_ROWCOUNT_ONPAGEUPDOWN);
this.selectNearestRow(this._selectedRowIdx - this.rowCount);
}
if (e.keyCode === Ci.nsIDOMKeyEvent.DOM_VK_PAGE_DOWN) {
e.preventDefault();
this.selectNearestRow(this._selectedRowIdx + WATERFALL_ROWCOUNT_ONPAGEUPDOWN);
this.selectNearestRow(this._selectedRowIdx + this.rowCount);
}
}, true);
},

View File

@ -33,6 +33,17 @@
pointer-events: none;
}
/* Details Panel */
#select-waterfall-view {
list-style-image: url(performance-icons.svg#waterfall);
}
#select-calltree-view {
list-style-image: url(performance-icons.svg#call-tree);
}
/* Profile call tree */
.theme-dark .call-tree-headers-container {
@ -255,3 +266,148 @@
transform: scale(0.75);
transform-origin: center right;
}
/**
* Details Waterfall Styles
*/
.waterfall-list-contents {
/* Hack: force hardware acceleration */
transform: translateZ(1px);
overflow-x: hidden;
overflow-y: auto;
}
.waterfall-header-contents {
overflow-x: hidden;
}
.waterfall-background-ticks {
/* Background created on a <canvas> in js. */
/* @see browser/devtools/timeline/widgets/waterfall.js */
background-image: -moz-element(#waterfall-background);
background-repeat: repeat-y;
background-position: -1px center;
}
.waterfall-marker-container[is-spacer] {
pointer-events: none;
}
.theme-dark .waterfall-marker-container:not([is-spacer]):nth-child(2n) {
background-color: rgba(255,255,255,0.03);
}
.theme-light .waterfall-marker-container:not([is-spacer]):nth-child(2n) {
background-color: rgba(128,128,128,0.03);
}
.theme-dark .waterfall-marker-container:hover {
background-color: rgba(255,255,255,0.1) !important;
}
.theme-light .waterfall-marker-container:hover {
background-color: rgba(128,128,128,0.1) !important;
}
.waterfall-marker-item {
overflow: hidden;
}
.waterfall-sidebar {
-moz-border-end: 1px solid;
}
.theme-dark .waterfall-sidebar {
-moz-border-end-color: #000;
}
.theme-light .waterfall-sidebar {
-moz-border-end-color: #aaa;
}
.waterfall-marker-container:hover > .waterfall-sidebar {
background-color: transparent;
}
.waterfall-header-name {
padding: 4px;
}
.waterfall-header-tick {
width: 100px;
font-size: 9px;
transform-origin: left center;
}
.theme-dark .waterfall-header-tick {
color: #a9bacb;
}
.theme-light .waterfall-header-tick {
color: #292e33;
}
.waterfall-header-tick:not(:first-child) {
-moz-margin-start: -100px !important; /* Don't affect layout. */
}
.waterfall-marker-bullet {
width: 8px;
height: 8px;
-moz-margin-start: 8px;
-moz-margin-end: 6px;
border: 1px solid;
border-radius: 1px;
}
.waterfall-marker-name {
font-size: 95%;
padding-bottom: 1px !important;
}
.waterfall-marker-bar {
height: 9px;
border: 1px solid;
border-radius: 1px;
transform-origin: left center;
}
.theme-light .waterfall-marker-container.selected > .waterfall-sidebar,
.theme-light .waterfall-marker-container.selected > .waterfall-marker-item {
background-color: #4c9ed9; /* Select Highlight Blue */
color: #f5f7fa; /* Light foreground text */
}
.theme-dark .waterfall-marker-container.selected > .waterfall-sidebar,
.theme-dark .waterfall-marker-container.selected > .waterfall-marker-item {
background-color: #1d4f73; /* Select Highlight Blue */
color: #f5f7fa; /* Light foreground text */
}
.waterfall-marker-container.selected .waterfall-marker-bullet,
.waterfall-marker-container.selected .waterfall-marker-bar {
border-color: initial!important;
}
#waterfall-details {
padding-top: 28px;
overflow: auto;
}
.marker-details-bullet {
width: 8px;
height: 8px;
margin: 0 8px;
border: 1px solid;
border-radius: 1px;
}
.marker-details-type {
font-size: 1.2em;
font-weight: bold;
}
.marker-details-duration {
font-weight: bold;
}

View File

@ -241,7 +241,14 @@ let TimelineActor = exports.TimelineActor = protocol.ActorClass({
}
clearTimeout(this._dataPullTimeout);
}, {}),
return this.docShells[0].now();
}, {
response: {
// Set as possibly nullable due to the end time possibly being
// undefined during destruction
value: RetVal("nullable:number")
}
}),
/**
* When a new window becomes available in the tabActor, start recording its