diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
index b50da629ff66..a6adb45ebaa1 100644
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1141,6 +1141,7 @@ pref("devtools.netmonitor.enabled", true);
// The default Network Monitor UI settings
pref("devtools.netmonitor.panes-network-details-width", 450);
pref("devtools.netmonitor.panes-network-details-height", 450);
+pref("devtools.netmonitor.statistics", true);
// Enable the Tilt inspector
pref("devtools.tilt.enabled", true);
diff --git a/browser/devtools/netmonitor/netmonitor-controller.js b/browser/devtools/netmonitor/netmonitor-controller.js
index ba38e1758658..c57b1eb2dfe0 100644
--- a/browser/devtools/netmonitor/netmonitor-controller.js
+++ b/browser/devtools/netmonitor/netmonitor-controller.js
@@ -57,35 +57,64 @@ const EVENTS = {
// When the response body is displayed in the UI.
RESPONSE_BODY_DISPLAYED: "NetMonitor:ResponseBodyAvailable",
- // When `onTabSelect` is fired and subsequently rendered
+ // When `onTabSelect` is fired and subsequently rendered.
TAB_UPDATED: "NetMonitor:TabUpdated",
- // Fired when Sidebar is finished being populated
+ // Fired when Sidebar has finished being populated.
SIDEBAR_POPULATED: "NetMonitor:SidebarPopulated",
- // Fired when NetworkDetailsView is finished being populated
+ // Fired when NetworkDetailsView has finished being populated.
NETWORKDETAILSVIEW_POPULATED: "NetMonitor:NetworkDetailsViewPopulated",
- // Fired when NetworkDetailsView is finished being populated
- CUSTOMREQUESTVIEW_POPULATED: "NetMonitor:CustomRequestViewPopulated"
+ // Fired when CustomRequestView has finished being populated.
+ CUSTOMREQUESTVIEW_POPULATED: "NetMonitor:CustomRequestViewPopulated",
+
+ // Fired when charts have been displayed in the PerformanceStatisticsView.
+ PLACEHOLDER_CHARTS_DISPLAYED: "NetMonitor:PlaceholderChartsDisplayed",
+ PRIMED_CACHE_CHART_DISPLAYED: "NetMonitor:PrimedChartsDisplayed",
+ EMPTY_CACHE_CHART_DISPLAYED: "NetMonitor:EmptyChartsDisplayed"
+};
+
+// Descriptions for what this frontend is currently doing.
+const ACTIVITY_TYPE = {
+ // Standing by and handling requests normally.
+ NONE: 0,
+
+ // Forcing the target to reload with cache enabled or disabled.
+ RELOAD: {
+ WITH_CACHE_ENABLED: 1,
+ WITH_CACHE_DISABLED: 2
+ },
+
+ // Enabling or disabling the cache without triggering a reload.
+ ENABLE_CACHE: 3,
+ DISABLE_CACHE: 4
};
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Task.jsm");
-Cu.import("resource:///modules/devtools/shared/event-emitter.js");
Cu.import("resource:///modules/devtools/SideMenuWidget.jsm");
Cu.import("resource:///modules/devtools/VariablesView.jsm");
Cu.import("resource:///modules/devtools/VariablesViewController.jsm");
Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
-const { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
const require = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}).devtools.require;
+const promise = Cu.import("resource://gre/modules/Promise.jsm", {}).Promise;
+const EventEmitter = require("devtools/shared/event-emitter");
const Editor = require("devtools/sourceeditor/editor");
+XPCOMUtils.defineLazyModuleGetter(this, "Chart",
+ "resource:///modules/devtools/Chart.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+
XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
"resource://gre/modules/PluralForm.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "DevToolsUtils",
+ "resource://gre/modules/devtools/DevToolsUtils.jsm");
+
XPCOMUtils.defineLazyModuleGetter(this, "devtools",
"resource://gre/modules/devtools/Loader.jsm");
@@ -255,9 +284,81 @@ let NetMonitorController = {
});
},
+ /**
+ * Gets the activity currently performed by the frontend.
+ * @return number
+ */
+ getCurrentActivity: function() {
+ return this._currentActivity || ACTIVITY_TYPE.NONE;
+ },
+
+ /**
+ * Triggers a specific "activity" to be performed by the frontend. This can be,
+ * for example, triggering reloads or enabling/disabling cache.
+ *
+ * @param number aType
+ * The activity type. See the ACTIVITY_TYPE const.
+ * @return object
+ * A promise resolved once the activity finishes and the frontend
+ * is back into "standby" mode.
+ */
+ triggerActivity: function(aType) {
+ // Puts the frontend into "standby" (when there's no particular activity).
+ let standBy = () => {
+ this._currentActivity = ACTIVITY_TYPE.NONE;
+ };
+
+ // Waits for a series of "navigation start" and "navigation stop" events.
+ let waitForNavigation = () => {
+ let deferred = promise.defer();
+ this._target.once("will-navigate", () => {
+ this._target.once("navigate", () => {
+ deferred.resolve();
+ });
+ });
+ return deferred.promise;
+ };
+
+ // Reconfigures the tab, optionally triggering a reload.
+ let reconfigureTab = aOptions => {
+ let deferred = promise.defer();
+ this._target.activeTab.reconfigure(aOptions, deferred.resolve);
+ return deferred.promise;
+ };
+
+ // Reconfigures the tab and waits for the target to finish navigating.
+ let reconfigureTabAndWaitForNavigation = aOptions => {
+ aOptions.performReload = true;
+ let navigationFinished = waitForNavigation();
+ return reconfigureTab(aOptions).then(() => navigationFinished);
+ }
+
+ if (aType == ACTIVITY_TYPE.RELOAD.WITH_CACHE_ENABLED) {
+ this._currentActivity = ACTIVITY_TYPE.ENABLE_CACHE;
+ this._target.once("will-navigate", () => this._currentActivity = aType);
+ return reconfigureTabAndWaitForNavigation({ cacheEnabled: true }).then(standBy);
+ }
+ if (aType == ACTIVITY_TYPE.RELOAD.WITH_CACHE_DISABLED) {
+ this._currentActivity = ACTIVITY_TYPE.DISABLE_CACHE;
+ this._target.once("will-navigate", () => this._currentActivity = aType);
+ return reconfigureTabAndWaitForNavigation({ cacheEnabled: false }).then(standBy);
+ }
+ if (aType == ACTIVITY_TYPE.ENABLE_CACHE) {
+ this._currentActivity = aType;
+ return reconfigureTab({ cacheEnabled: true, performReload: false }).then(standBy);
+ }
+ if (aType == ACTIVITY_TYPE.DISABLE_CACHE) {
+ this._currentActivity = aType;
+ return reconfigureTab({ cacheEnabled: false, performReload: false }).then(standBy);
+ }
+ this._currentActivity = ACTIVITY_TYPE.NONE;
+ return promise.reject(new Error("Invalid activity type"));
+ },
+
_startup: null,
_shutdown: null,
_connection: null,
+ _currentActivity: null,
client: null,
tabClient: null,
webConsoleClient: null
@@ -314,6 +415,11 @@ TargetEventsHandler.prototype = {
NetMonitorView.Sidebar.reset();
NetMonitorView.NetworkDetails.reset();
+ // Switch to the default network traffic inspector view.
+ if (NetMonitorController.getCurrentActivity() == ACTIVITY_TYPE.NONE) {
+ NetMonitorView.showNetworkInspectorView();
+ }
+
window.emit(EVENTS.TARGET_WILL_NAVIGATE);
break;
}
@@ -383,7 +489,6 @@ NetworkEventsHandler.prototype = {
_onNetworkEvent: function(aType, aPacket) {
let { actor, startedDateTime, method, url, isXHR } = aPacket.eventActor;
NetMonitorView.RequestsMenu.addRequest(actor, startedDateTime, method, url, isXHR);
-
window.emit(EVENTS.NETWORK_EVENT);
},
@@ -585,7 +690,8 @@ let L10N = new ViewHelpers.L10N(NET_STRINGS_URI);
*/
let Prefs = new ViewHelpers.Prefs("devtools.netmonitor", {
networkDetailsWidth: ["Int", "panes-network-details-width"],
- networkDetailsHeight: ["Int", "panes-network-details-height"]
+ networkDetailsHeight: ["Int", "panes-network-details-height"],
+ statistics: ["Bool", "statistics"]
});
/**
@@ -616,6 +722,39 @@ Object.defineProperties(window, {
}
});
+/**
+ * Makes sure certain properties are available on all objects in a data store.
+ *
+ * @param array aDataStore
+ * A list of objects for which to check the availability of properties.
+ * @param array aMandatoryFields
+ * A list of strings representing properties of objects in aDataStore.
+ * @return object
+ * A promise resolved when all objects in aDataStore contain the
+ * properties defined in aMandatoryFields.
+ */
+function whenDataAvailable(aDataStore, aMandatoryFields) {
+ let deferred = promise.defer();
+
+ let interval = setInterval(() => {
+ if (aDataStore.every(item => aMandatoryFields.every(field => field in item))) {
+ clearInterval(interval);
+ clearTimeout(timer);
+ deferred.resolve();
+ }
+ }, WDA_DEFAULT_VERIFY_INTERVAL);
+
+ let timer = setTimeout(() => {
+ clearInterval(interval);
+ deferred.reject(new Error("Timed out while waiting for data"));
+ }, WDA_DEFAULT_GIVE_UP_TIMEOUT);
+
+ return deferred.promise;
+};
+
+const WDA_DEFAULT_VERIFY_INTERVAL = 50; // ms
+const WDA_DEFAULT_GIVE_UP_TIMEOUT = 2000; // ms
+
/**
* Helper method for debugging.
* @param string
diff --git a/browser/devtools/netmonitor/netmonitor-view.js b/browser/devtools/netmonitor/netmonitor-view.js
index e82dc0eead0d..106a52a2d0be 100644
--- a/browser/devtools/netmonitor/netmonitor-view.js
+++ b/browser/devtools/netmonitor/netmonitor-view.js
@@ -21,6 +21,7 @@ const REQUESTS_WATERFALL_BACKGROUND_TICKS_COLOR_RGB = [128, 136, 144];
const REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_MIN = 32; // byte
const REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_ADD = 32; // byte
const DEFAULT_HTTP_VERSION = "HTTP/1.1";
+const REQUEST_TIME_DECIMALS = 2;
const HEADERS_SIZE_DECIMALS = 3;
const CONTENT_SIZE_DECIMALS = 2;
const CONTENT_MIME_TYPE_ABBREVIATIONS = {
@@ -57,6 +58,7 @@ const GENERIC_VARIABLES_VIEW_SETTINGS = {
eval: () => {},
switch: () => {}
};
+const NETWORK_ANALYSIS_PIE_CHART_DIAMETER = 200; // px
/**
* Object defining the network monitor view components.
@@ -102,6 +104,14 @@ let NetMonitorView = {
this._detailsPane.setAttribute("width", Prefs.networkDetailsWidth);
this._detailsPane.setAttribute("height", Prefs.networkDetailsHeight);
this.toggleDetailsPane({ visible: false });
+
+ // Disable the performance statistics mode.
+ if (!Prefs.statistics) {
+ $("#request-menu-context-perf").hidden = true;
+ $("#notice-perf-message").hidden = true;
+ $("#requests-menu-network-summary-button").hidden = true;
+ $("#requests-menu-network-summary-label").hidden = true;
+ }
},
/**
@@ -121,8 +131,9 @@ let NetMonitorView = {
* Gets the visibility state of the network details pane.
* @return boolean
*/
- get detailsPaneHidden()
- this._detailsPane.hasAttribute("pane-collapsed"),
+ get detailsPaneHidden() {
+ return this._detailsPane.hasAttribute("pane-collapsed");
+ },
/**
* Sets the network details pane hidden or visible.
@@ -157,6 +168,66 @@ let NetMonitorView = {
}
},
+ /**
+ * Gets the current mode for this tool.
+ * @return string (e.g, "network-inspector-view" or "network-statistics-view")
+ */
+ get currentFrontendMode() {
+ return this._body.selectedPanel.id;
+ },
+
+ /**
+ * Toggles between the frontend view modes ("Inspector" vs. "Statistics").
+ */
+ toggleFrontendMode: function() {
+ if (this.currentFrontendMode != "network-inspector-view") {
+ this.showNetworkInspectorView();
+ } else {
+ this.showNetworkStatisticsView();
+ }
+ },
+
+ /**
+ * Switches to the "Inspector" frontend view mode.
+ */
+ showNetworkInspectorView: function() {
+ this._body.selectedPanel = $("#network-inspector-view");
+ this.RequestsMenu._flushWaterfallViews(true);
+ },
+
+ /**
+ * Switches to the "Statistics" frontend view mode.
+ */
+ showNetworkStatisticsView: function() {
+ this._body.selectedPanel = $("#network-statistics-view");
+
+ let controller = NetMonitorController;
+ let requestsView = this.RequestsMenu;
+ let statisticsView = this.PerformanceStatistics;
+
+ Task.spawn(function() {
+ statisticsView.displayPlaceholderCharts();
+ yield controller.triggerActivity(ACTIVITY_TYPE.RELOAD.WITH_CACHE_ENABLED);
+
+ try {
+ // • The response headers and status code are required for determining
+ // whether a response is "fresh" (cacheable).
+ // • The response content size and request total time are necessary for
+ // populating the statistics view.
+ // • The response mime type is used for categorization.
+ yield whenDataAvailable(requestsView.attachments, [
+ "responseHeaders", "status", "contentSize", "mimeType", "totalTime"
+ ]);
+ } catch (ex) {
+ // Timed out while waiting for data. Continue with what we have.
+ DevToolsUtils.reportException("showNetworkStatisticsView", ex);
+ }
+
+ statisticsView.createPrimedCacheChart(requestsView.items);
+ statisticsView.createEmptyCacheChart(requestsView.items);
+ });
+ },
+
/**
* Lazily initializes and returns a promise for a Editor instance.
*
@@ -263,8 +334,9 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
dumpn("Initializing the RequestsMenuView");
this.widget = new SideMenuWidget($("#requests-menu-contents"));
- this._splitter = $('#splitter');
- this._summary = $("#request-menu-network-summary");
+ this._splitter = $("#network-inspector-view-splitter");
+ this._summary = $("#requests-menu-network-summary-label");
+ this._summary.setAttribute("value", L10N.getStr("networkMenu.empty"));
this.allowFocusOnRightClick = true;
this.widget.maintainSelectionVisible = false;
@@ -276,11 +348,12 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
this.requestsMenuSortEvent = getKeyWithEvent(this.sortBy.bind(this));
this.requestsMenuFilterEvent = getKeyWithEvent(this.filterOn.bind(this));
- this.clearEvent = this.clear.bind(this);
+ this.reqeustsMenuClearEvent = this.clear.bind(this);
this._onContextShowing = this._onContextShowing.bind(this);
this._onContextNewTabCommand = this.openRequestInTab.bind(this);
this._onContextCopyUrlCommand = this.copyUrl.bind(this);
this._onContextResendCommand = this.cloneSelectedRequest.bind(this);
+ this._onContextPerfCommand = () => NetMonitorView.toggleFrontendMode();
this.sendCustomRequestEvent = this.sendCustomRequest.bind(this);
this.closeCustomRequestEvent = this.closeCustomRequest.bind(this);
@@ -288,11 +361,16 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
$("#toolbar-labels").addEventListener("click", this.requestsMenuSortEvent, false);
$("#requests-menu-footer").addEventListener("click", this.requestsMenuFilterEvent, false);
- $("#requests-menu-clear-button").addEventListener("click", this.clearEvent, false);
+ $("#requests-menu-clear-button").addEventListener("click", this.reqeustsMenuClearEvent, false);
$("#network-request-popup").addEventListener("popupshowing", this._onContextShowing, false);
$("#request-menu-context-newtab").addEventListener("command", this._onContextNewTabCommand, false);
$("#request-menu-context-copy-url").addEventListener("command", this._onContextCopyUrlCommand, false);
$("#request-menu-context-resend").addEventListener("command", this._onContextResendCommand, false);
+ $("#request-menu-context-perf").addEventListener("command", this._onContextPerfCommand, false);
+
+ $("#requests-menu-perf-notice-button").addEventListener("command", this._onContextPerfCommand, false);
+ $("#requests-menu-network-summary-button").addEventListener("command", this._onContextPerfCommand, false);
+ $("#requests-menu-network-summary-label").addEventListener("click", this._onContextPerfCommand, false);
$("#custom-request-send-button").addEventListener("click", this.sendCustomRequestEvent, false);
$("#custom-request-close-button").addEventListener("click", this.closeCustomRequestEvent, false);
@@ -311,11 +389,16 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
$("#toolbar-labels").removeEventListener("click", this.requestsMenuSortEvent, false);
$("#requests-menu-footer").removeEventListener("click", this.requestsMenuFilterEvent, false);
- $("#requests-menu-clear-button").removeEventListener("click", this.clearEvent, false);
+ $("#requests-menu-clear-button").removeEventListener("click", this.reqeustsMenuClearEvent, false);
$("#network-request-popup").removeEventListener("popupshowing", this._onContextShowing, false);
$("#request-menu-context-newtab").removeEventListener("command", this._onContextNewTabCommand, false);
$("#request-menu-context-copy-url").removeEventListener("command", this._onContextCopyUrlCommand, false);
$("#request-menu-context-resend").removeEventListener("command", this._onContextResendCommand, false);
+ $("#request-menu-context-perf").removeEventListener("command", this._onContextPerfCommand, false);
+
+ $("#requests-menu-perf-notice-button").removeEventListener("command", this._onContextPerfCommand, false);
+ $("#requests-menu-network-summary-button").removeEventListener("command", this._onContextPerfCommand, false);
+ $("#requests-menu-network-summary-label").removeEventListener("click", this._onContextPerfCommand, false);
$("#custom-request-send-button").removeEventListener("click", this.sendCustomRequestEvent, false);
$("#custom-request-close-button").removeEventListener("click", this.closeCustomRequestEvent, false);
@@ -327,6 +410,7 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
*/
reset: function() {
this.empty();
+ this.filterOn("all");
this._firstRequestStartedMillis = -1;
this._lastRequestEndedMillis = -1;
},
@@ -394,6 +478,7 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
// Create the element node for the network request item.
let menuView = this._createMenuView(selected.method, selected.url);
+ // Append a network request item to this container.
let newItem = this.push([menuView], {
attachment: Object.create(selected, {
isCustom: { value: true }
@@ -457,7 +542,7 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
*
* @param string aType
* Either "all", "html", "css", "js", "xhr", "fonts", "images", "media"
- * or "flash".
+ * "flash" or "other".
*/
filterOn: function(aType = "all") {
let target = $("#requests-menu-filter-" + aType + "-button");
@@ -477,28 +562,31 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
this.filterContents(() => true);
break;
case "html":
- this.filterContents(this._onHtml);
+ this.filterContents(e => this.isHtml(e));
break;
case "css":
- this.filterContents(this._onCss);
+ this.filterContents(e => this.isCss(e));
break;
case "js":
- this.filterContents(this._onJs);
+ this.filterContents(e => this.isJs(e));
break;
case "xhr":
- this.filterContents(this._onXhr);
+ this.filterContents(e => this.isXHR(e));
break;
case "fonts":
- this.filterContents(this._onFonts);
+ this.filterContents(e => this.isFont(e));
break;
case "images":
- this.filterContents(this._onImages);
+ this.filterContents(e => this.isImage(e));
break;
case "media":
- this.filterContents(this._onMedia);
+ this.filterContents(e => this.isMedia(e));
break;
case "flash":
- this.filterContents(this._onFlash);
+ this.filterContents(e => this.isFlash(e));
+ break;
+ case "other":
+ this.filterContents(e => this.isOther(e));
break;
}
@@ -611,22 +699,22 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
* @return boolean
* True if the item should be visible, false otherwise.
*/
- _onHtml: function({ attachment: { mimeType } })
+ isHtml: function({ attachment: { mimeType } })
mimeType && mimeType.contains("/html"),
- _onCss: function({ attachment: { mimeType } })
+ isCss: function({ attachment: { mimeType } })
mimeType && mimeType.contains("/css"),
- _onJs: function({ attachment: { mimeType } })
+ isJs: function({ attachment: { mimeType } })
mimeType && (
mimeType.contains("/ecmascript") ||
mimeType.contains("/javascript") ||
mimeType.contains("/x-javascript")),
- _onXhr: function({ attachment: { isXHR } })
+ isXHR: function({ attachment: { isXHR } })
isXHR,
- _onFonts: function({ attachment: { url, mimeType } }) // Fonts are a mess.
+ isFont: function({ attachment: { url, mimeType } }) // Fonts are a mess.
(mimeType && (
mimeType.contains("font/") ||
mimeType.contains("/font"))) ||
@@ -635,22 +723,26 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
url.contains(".otf") ||
url.contains(".woff"),
- _onImages: function({ attachment: { mimeType } })
+ isImage: function({ attachment: { mimeType } })
mimeType && mimeType.contains("image/"),
- _onMedia: function({ attachment: { mimeType } }) // Not including images.
+ isMedia: function({ attachment: { mimeType } }) // Not including images.
mimeType && (
mimeType.contains("audio/") ||
mimeType.contains("video/") ||
mimeType.contains("model/")),
- _onFlash: function({ attachment: { url, mimeType } }) // Flash is a mess.
+ isFlash: function({ attachment: { url, mimeType } }) // Flash is a mess.
(mimeType && (
mimeType.contains("/x-flv") ||
mimeType.contains("/x-shockwave-flash"))) ||
url.contains(".swf") ||
url.contains(".flv"),
+ isOther: function(e)
+ !this.isHtml(e) && !this.isCss(e) && !this.isJs(e) && !this.isXHR(e) &&
+ !this.isFont(e) && !this.isImage(e) && !this.isMedia(e) && !this.isFlash(e),
+
/**
* Predicates used when sorting items.
*
@@ -724,8 +816,8 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
let str = PluralForm.get(visibleRequestsCount, L10N.getStr("networkMenu.summary"));
this._summary.setAttribute("value", str
.replace("#1", visibleRequestsCount)
- .replace("#2", L10N.numberWithDecimals((totalBytes || 0) / 1024, 2))
- .replace("#3", L10N.numberWithDecimals((totalMillis || 0) / 1000, 2))
+ .replace("#2", L10N.numberWithDecimals((totalBytes || 0) / 1024, CONTENT_SIZE_DECIMALS))
+ .replace("#3", L10N.numberWithDecimals((totalMillis || 0) / 1000, REQUEST_TIME_DECIMALS))
);
},
@@ -838,6 +930,12 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
break;
case "responseContent":
requestItem.attachment.responseContent = value;
+ // If there's no mime type available when the response content
+ // is received, assume text/plain as a fallback.
+ if (!requestItem.attachment.mimeType) {
+ requestItem.attachment.mimeType = "text/plain";
+ this.updateMenuView(requestItem, "mimeType", "text/plain");
+ }
break;
case "totalTime":
requestItem.attachment.totalTime = value;
@@ -1021,6 +1119,11 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
startCapNode.hidden = false;
endCapNode.hidden = false;
+ // Don't paint things while the waterfall view isn't even visible.
+ if (NetMonitorView.currentFrontendMode != "network-inspector-view") {
+ return;
+ }
+
// Rescale all the waterfalls so that everything is visible at once.
this._flushWaterfallViews();
},
@@ -1134,7 +1237,7 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
if (divisionScale == "millisecond") {
normalizedTime |= 0;
} else {
- normalizedTime = L10N.numberWithDecimals(normalizedTime, 2);
+ normalizedTime = L10N.numberWithDecimals(normalizedTime, REQUEST_TIME_DECIMALS);
}
let node = document.createElement("label");
@@ -1263,6 +1366,11 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
* The resize listener for this container's window.
*/
_onResize: function(e) {
+ // Don't paint things while the waterfall view isn't even visible.
+ if (NetMonitorView.currentFrontendMode != "network-inspector-view") {
+ return;
+ }
+
// Allow requests to settle down first.
setNamedTimeout(
"resize-events", RESIZE_REFRESH_RATE, () => this._flushWaterfallViews(true));
@@ -1453,7 +1561,7 @@ SidebarView.prototype = {
return view.populate(aData).then(() => {
$("#details-pane").selectedIndex = isCustom ? 0 : 1
- window.emit(EVENTS.SIDEBAR_POPULATED)
+ window.emit(EVENTS.SIDEBAR_POPULATED);
});
},
@@ -1480,7 +1588,6 @@ CustomRequestView.prototype = {
dumpn("Initializing the CustomRequestView");
this.updateCustomRequestEvent = getKeyWithEvent(this.onUpdate.bind(this));
-
$("#custom-pane").addEventListener("input", this.updateCustomRequestEvent, false);
},
@@ -1555,18 +1662,12 @@ CustomRequestView.prototype = {
break;
case 'body':
value = $("#custom-postdata-value").value;
- selectedItem.attachment.requestPostData = {
- postData: {
- text: value
- }
- };
+ selectedItem.attachment.requestPostData = { postData: { text: value } };
break;
case 'headers':
let headersText = $("#custom-headers-value").value;
value = parseHeaderText(headersText);
- selectedItem.attachment.requestHeaders = {
- headers: value
- };
+ selectedItem.attachment.requestHeaders = { headers: value };
break;
}
@@ -2172,6 +2273,170 @@ NetworkDetailsView.prototype = {
_responseCookies: ""
};
+/**
+ * Functions handling the performance statistics view.
+ */
+function PerformanceStatisticsView() {
+}
+
+PerformanceStatisticsView.prototype = {
+ /**
+ * Initializes and displays empty charts in this container.
+ */
+ displayPlaceholderCharts: function() {
+ this._createChart({
+ id: "#primed-cache-chart",
+ title: "charts.cacheEnabled"
+ });
+ this._createChart({
+ id: "#empty-cache-chart",
+ title: "charts.cacheDisabled"
+ });
+ window.emit(EVENTS.PLACEHOLDER_CHARTS_DISPLAYED);
+ },
+
+ /**
+ * Populates and displays the primed cache chart in this container.
+ *
+ * @param array aItems
+ * @see this._sanitizeChartDataSource
+ */
+ createPrimedCacheChart: function(aItems) {
+ this._createChart({
+ id: "#primed-cache-chart",
+ title: "charts.cacheEnabled",
+ data: this._sanitizeChartDataSource(aItems),
+ sorted: true,
+ totals: {
+ size: L10N.getStr("charts.totalSize"),
+ time: L10N.getStr("charts.totalTime"),
+ cached: L10N.getStr("charts.totalCached"),
+ count: L10N.getStr("charts.totalCount")
+ }
+ });
+ window.emit(EVENTS.PRIMED_CACHE_CHART_DISPLAYED);
+ },
+
+ /**
+ * Populates and displays the empty cache chart in this container.
+ *
+ * @param array aItems
+ * @see this._sanitizeChartDataSource
+ */
+ createEmptyCacheChart: function(aItems) {
+ this._createChart({
+ id: "#empty-cache-chart",
+ title: "charts.cacheDisabled",
+ data: this._sanitizeChartDataSource(aItems, true),
+ sorted: true,
+ totals: {
+ size: L10N.getStr("charts.totalSize"),
+ time: L10N.getStr("charts.totalTime"),
+ cached: L10N.getStr("charts.totalCached"),
+ count: L10N.getStr("charts.totalCount")
+ }
+ });
+ window.emit(EVENTS.EMPTY_CACHE_CHART_DISPLAYED);
+ },
+
+ /**
+ * Adds a specific chart to this container.
+ *
+ * @param object
+ * An object containing all or some the following properties:
+ * - id: either "#primed-cache-chart" or "#empty-cache-chart"
+ * - title/data/sorted/totals: @see Chart.jsm for details
+ */
+ _createChart: function({ id, title, data, sorted, totals }) {
+ let container = $(id);
+
+ // Nuke all existing charts of the specified type.
+ while (container.hasChildNodes()) {
+ container.firstChild.remove();
+ }
+
+ // Create a new chart.
+ let chart = Chart.PieTable(document, {
+ diameter: NETWORK_ANALYSIS_PIE_CHART_DIAMETER,
+ title: L10N.getStr(title),
+ data: data,
+ sorted: sorted,
+ totals: totals
+ });
+
+ chart.on("click", (_, item) => {
+ NetMonitorView.RequestsMenu.filterOn(item.label);
+ NetMonitorView.showNetworkInspectorView();
+ });
+
+ container.appendChild(chart.node);
+ },
+
+ /**
+ * Sanitizes the data source used for creating charts, to follow the
+ * data format spec defined in Chart.jsm.
+ *
+ * @param array aItems
+ * A collection of request items used as the data source for the chart.
+ * @param boolean aEmptyCache
+ * True if the cache is considered enabled, false for disabled.
+ */
+ _sanitizeChartDataSource: function(aItems, aEmptyCache) {
+ let data = [
+ "html", "css", "js", "xhr", "fonts", "images", "media", "flash", "other"
+ ].map(e => ({
+ cached: 0,
+ count: 0,
+ label: e,
+ size: 0,
+ time: 0
+ }));
+
+ for (let requestItem of aItems) {
+ let details = requestItem.attachment;
+ let type;
+
+ if (RequestsMenuView.prototype.isHtml(requestItem)) {
+ type = 0; // "html"
+ } else if (RequestsMenuView.prototype.isCss(requestItem)) {
+ type = 1; // "css"
+ } else if (RequestsMenuView.prototype.isJs(requestItem)) {
+ type = 2; // "js"
+ } else if (RequestsMenuView.prototype.isFont(requestItem)) {
+ type = 4; // "fonts"
+ } else if (RequestsMenuView.prototype.isImage(requestItem)) {
+ type = 5; // "images"
+ } else if (RequestsMenuView.prototype.isMedia(requestItem)) {
+ type = 6; // "media"
+ } else if (RequestsMenuView.prototype.isFlash(requestItem)) {
+ type = 7; // "flash"
+ } else if (RequestsMenuView.prototype.isXHR(requestItem)) {
+ // Verify XHR last, to categorize other mime types in their own blobs.
+ type = 3; // "xhr"
+ } else {
+ type = 8; // "other"
+ }
+
+ if (aEmptyCache || !responseIsFresh(details)) {
+ data[type].time += details.totalTime || 0;
+ data[type].size += details.contentSize || 0;
+ } else {
+ data[type].cached++;
+ }
+ data[type].count++;
+ }
+
+ for (let chartItem of data) {
+ let size = L10N.numberWithDecimals(chartItem.size / 1024, CONTENT_SIZE_DECIMALS);
+ let time = L10N.numberWithDecimals(chartItem.time / 1000, REQUEST_TIME_DECIMALS);
+ chartItem.size = L10N.getFormatStr("charts.sizeKB", size);
+ chartItem.time = L10N.getFormatStr("charts.totalMS", time);
+ }
+
+ return data.filter(e => e.count > 0);
+ },
+};
+
/**
* DOM query helper.
*/
@@ -2194,8 +2459,8 @@ nsIURL.store = new Map();
/**
* Parse a url's query string into its components
*
- * @param string aQueryString
- * The query part of a url
+ * @param string aQueryString
+ * The query part of a url
* @return array
* Array of query params {name, value}
*/
@@ -2216,8 +2481,8 @@ function parseQueryString(aQueryString) {
/**
* Parse text representation of HTTP headers.
*
- * @param string aText
- * Text of headers
+ * @param string aText
+ * Text of headers
* @return array
* Array of headers info {name, value}
*/
@@ -2228,8 +2493,8 @@ function parseHeaderText(aText) {
/**
* Parse readable text list of a query string.
*
- * @param string aText
- * Text of query string represetation
+ * @param string aText
+ * Text of query string represetation
* @return array
* Array of query params {name, value}
*/
@@ -2241,8 +2506,8 @@ function parseQueryText(aText) {
* Parse a text representation of a name:value list with
* the given name:value divider character.
*
- * @param string aText
- * Text of list
+ * @param string aText
+ * Text of list
* @return array
* Array of headers info {name, value}
*/
@@ -2295,6 +2560,44 @@ function writeQueryString(aParams) {
return [(name + "=" + value) for ({name, value} of aParams)].join("&");
}
+/**
+ * Checks if the "Expiration Calculations" defined in section 13.2.4 of the
+ * "HTTP/1.1: Caching in HTTP" spec holds true for a collection of headers.
+ *
+ * @param object
+ * An object containing the { responseHeaders, status } properties.
+ * @return boolean
+ * True if the response is fresh and loaded from cache.
+ */
+function responseIsFresh({ responseHeaders, status }) {
+ // Check for a "304 Not Modified" status and response headers availability.
+ if (status != 304 || !responseHeaders) {
+ return false;
+ }
+
+ let list = responseHeaders.headers;
+ let cacheControl = list.filter(e => e.name.toLowerCase() == "cache-control")[0];
+ let expires = list.filter(e => e.name.toLowerCase() == "expires")[0];
+
+ // Check the "Cache-Control" header for a maximum age value.
+ if (cacheControl) {
+ let maxAgeMatch =
+ cacheControl.value.match(/s-maxage\s*=\s*(\d+)/) ||
+ cacheControl.value.match(/max-age\s*=\s*(\d+)/);
+
+ if (maxAgeMatch && maxAgeMatch.pop() > 0) {
+ return true;
+ }
+ }
+
+ // Check the "Expires" header for a valid date.
+ if (expires && Date.parse(expires.value)) {
+ return true;
+ }
+
+ return false;
+}
+
/**
* Helper method to get a wrapped function which can be bound to as an event listener directly and is executed only when data-key is present in event.target.
*
@@ -2320,3 +2623,4 @@ NetMonitorView.RequestsMenu = new RequestsMenuView();
NetMonitorView.Sidebar = new SidebarView();
NetMonitorView.CustomRequest = new CustomRequestView();
NetMonitorView.NetworkDetails = new NetworkDetailsView();
+NetMonitorView.PerformanceStatistics = new PerformanceStatisticsView();
diff --git a/browser/devtools/netmonitor/netmonitor.css b/browser/devtools/netmonitor/netmonitor.css
index 39e98a0d7133..ef8e457221ab 100644
--- a/browser/devtools/netmonitor/netmonitor.css
+++ b/browser/devtools/netmonitor/netmonitor.css
@@ -12,11 +12,11 @@
visibility: hidden;
}
-#response-content-image-box {
+#custom-pane {
overflow: auto;
}
-#custom-pane {
+#response-content-image-box {
overflow: auto;
}
@@ -24,6 +24,10 @@
display: none; /* This doesn't work yet. */
}
+#network-statistics-charts {
+ overflow: auto;
+}
+
/* Responsive sidebar */
@media (max-width: 700px) {
#toolbar-spacer,
@@ -35,18 +39,14 @@
}
}
-@media (min-width: 701px) and (max-width: 1024px) {
- #body:not([pane-collapsed]) .requests-menu-footer-button,
+@media (min-width: 701px) and (max-width: 1280px) {
+ #body:not([pane-collapsed]) .requests-menu-filter-button,
#body:not([pane-collapsed]) .requests-menu-footer-spacer {
display: none;
}
}
@media (min-width: 701px) {
- #requests-menu-spacer-start {
- display: none;
- }
-
#network-table[waterfall-overflows] .requests-menu-waterfall {
display: none;
}
diff --git a/browser/devtools/netmonitor/netmonitor.xul b/browser/devtools/netmonitor/netmonitor.xul
index 34bf0c1d49de..274ba5266efe 100644
--- a/browser/devtools/netmonitor/netmonitor.xul
+++ b/browser/devtools/netmonitor/netmonitor.xul
@@ -31,12 +31,16 @@
+
+
-
+
+
+
-
+
+
+
-
+
@@ -460,4 +475,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/browser/devtools/netmonitor/test/browser.ini b/browser/devtools/netmonitor/test/browser.ini
index 51b94cf27ee9..f2e23815ae80 100644
--- a/browser/devtools/netmonitor/test/browser.ini
+++ b/browser/devtools/netmonitor/test/browser.ini
@@ -15,6 +15,7 @@ support-files =
html_post-raw-test-page.html
html_simple-test-page.html
html_sorting-test-page.html
+ html_statistics-test-page.html
html_status-codes-test-page.html
sjs_content-type-test-server.sjs
sjs_simple-test-server.sjs
@@ -26,6 +27,11 @@ support-files =
[browser_net_accessibility-01.js]
[browser_net_accessibility-02.js]
[browser_net_autoscroll.js]
+[browser_net_charts-01.js]
+[browser_net_charts-02.js]
+[browser_net_charts-03.js]
+[browser_net_charts-04.js]
+[browser_net_charts-05.js]
[browser_net_clear.js]
[browser_net_content-type.js]
[browser_net_copy_url.js]
@@ -57,6 +63,8 @@ support-files =
[browser_net_sort-01.js]
[browser_net_sort-02.js]
[browser_net_sort-03.js]
+[browser_net_statistics-01.js]
+[browser_net_statistics-02.js]
[browser_net_status-codes.js]
[browser_net_timeline_ticks.js]
[browser_net_timing-division.js]
diff --git a/browser/devtools/netmonitor/test/browser_net_charts-01.js b/browser/devtools/netmonitor/test/browser_net_charts-01.js
new file mode 100644
index 000000000000..8f21a61c8ca3
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_charts-01.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Makes sure Pie Charts have the right internal structure.
+ */
+
+function test() {
+ initNetMonitor(SIMPLE_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let { document, Chart } = aMonitor.panelWin;
+ let container = document.createElement("box");
+
+ let pie = Chart.Pie(document, {
+ width: 100,
+ height: 100,
+ data: [{
+ size: 1,
+ label: "foo"
+ }, {
+ size: 2,
+ label: "bar"
+ }, {
+ size: 3,
+ label: "baz"
+ }]
+ });
+
+ let node = pie.node;
+ let slices = node.querySelectorAll(".pie-chart-slice.chart-colored-blob");
+ let labels = node.querySelectorAll(".pie-chart-label");
+
+ ok(node.classList.contains("pie-chart-container") &&
+ node.classList.contains("generic-chart-container"),
+ "A pie chart container was created successfully.");
+
+ is(slices.length, 3,
+ "There should be 3 pie chart slices created.");
+ ok(slices[0].getAttribute("d").match(/\s*M 50,50 L 49\.\d+,97\.\d+ A 47\.5,47\.5 0 0 1 49\.\d+,2\.5\d* Z/),
+ "The first slice has the correct data.");
+ ok(slices[1].getAttribute("d").match(/\s*M 50,50 L 91\.\d+,26\.\d+ A 47\.5,47\.5 0 0 1 49\.\d+,97\.\d+ Z/),
+ "The second slice has the correct data.");
+ ok(slices[2].getAttribute("d").match(/\s*M 50,50 L 50\.\d+,2\.5\d* A 47\.5,47\.5 0 0 1 91\.\d+,26\.\d+ Z/),
+ "The third slice has the correct data.");
+
+ ok(slices[0].hasAttribute("largest"),
+ "The first slice should be the largest one.");
+ ok(slices[2].hasAttribute("smallest"),
+ "The third slice should be the smallest one.");
+
+ ok(slices[0].getAttribute("name"), "baz",
+ "The first slice's name is correct.");
+ ok(slices[1].getAttribute("name"), "bar",
+ "The first slice's name is correct.");
+ ok(slices[2].getAttribute("name"), "foo",
+ "The first slice's name is correct.");
+
+ is(labels.length, 3,
+ "There should be 3 pie chart labels created.");
+ is(labels[0].textContent, "baz",
+ "The first label's text is correct.");
+ is(labels[1].textContent, "bar",
+ "The first label's text is correct.");
+ is(labels[2].textContent, "foo",
+ "The first label's text is correct.");
+
+ teardown(aMonitor).then(finish);
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_charts-02.js b/browser/devtools/netmonitor/test/browser_net_charts-02.js
new file mode 100644
index 000000000000..baf32002b89f
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_charts-02.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Makes sure Pie Charts have the right internal structure when
+ * initialized with empty data.
+ */
+
+function test() {
+ initNetMonitor(SIMPLE_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let { document, L10N, Chart } = aMonitor.panelWin;
+ let container = document.createElement("box");
+
+ let pie = Chart.Pie(document, {
+ data: null,
+ width: 100,
+ height: 100
+ });
+
+ let node = pie.node;
+ let slices = node.querySelectorAll(".pie-chart-slice.chart-colored-blob");
+ let labels = node.querySelectorAll(".pie-chart-label");
+
+ ok(node.classList.contains("pie-chart-container") &&
+ node.classList.contains("generic-chart-container"),
+ "A pie chart container was created successfully.");
+
+ is(slices.length, 1,
+ "There should be 1 pie chart slice created.");
+ ok(slices[0].getAttribute("d").match(/\s*M 50,50 L 50\.\d+,2\.5\d* A 47\.5,47\.5 0 1 1 49\.\d+,2\.5\d* Z/),
+ "The first slice has the correct data.");
+
+ ok(slices[0].hasAttribute("largest"),
+ "The first slice should be the largest one.");
+ ok(slices[0].hasAttribute("smallest"),
+ "The first slice should also be the smallest one.");
+ ok(slices[0].getAttribute("name"), L10N.getStr("pieChart.empty"),
+ "The first slice's name is correct.");
+
+ is(labels.length, 1,
+ "There should be 1 pie chart label created.");
+ is(labels[0].textContent, "Loading",
+ "The first label's text is correct.");
+
+ teardown(aMonitor).then(finish);
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_charts-03.js b/browser/devtools/netmonitor/test/browser_net_charts-03.js
new file mode 100644
index 000000000000..a708a6edb40b
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_charts-03.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Makes sure Table Charts have the right internal structure.
+ */
+
+function test() {
+ initNetMonitor(SIMPLE_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let { document, Chart } = aMonitor.panelWin;
+ let container = document.createElement("box");
+
+ let table = Chart.Table(document, {
+ title: "Table title",
+ data: [{
+ label1: 1,
+ label2: "11.1foo"
+ }, {
+ label1: 2,
+ label2: "12.2bar"
+ }, {
+ label1: 3,
+ label2: "13.3baz"
+ }],
+ totals: {
+ label1: "Hello %S",
+ label2: "World %S"
+ }
+ });
+
+ let node = table.node;
+ let title = node.querySelector(".table-chart-title");
+ let grid = node.querySelector(".table-chart-grid");
+ let totals = node.querySelector(".table-chart-totals");
+ let rows = grid.querySelectorAll(".table-chart-row");
+ let sums = node.querySelectorAll(".table-chart-summary-label");
+
+ ok(node.classList.contains("table-chart-container") &&
+ node.classList.contains("generic-chart-container"),
+ "A table chart container was created successfully.");
+
+ ok(title,
+ "A title node was created successfully.");
+ is(title.getAttribute("value"), "Table title",
+ "The title node displays the correct text.");
+
+ is(rows.length, 3,
+ "There should be 3 table chart rows created.");
+
+ ok(rows[0].querySelector(".table-chart-row-box.chart-colored-blob"),
+ "A colored blob exists for the firt row.");
+ is(rows[0].querySelectorAll("label")[0].getAttribute("name"), "label1",
+ "The first column of the first row exists.");
+ is(rows[0].querySelectorAll("label")[1].getAttribute("name"), "label2",
+ "The second column of the first row exists.");
+ is(rows[0].querySelectorAll("label")[0].getAttribute("value"), "1",
+ "The first column of the first row displays the correct text.");
+ is(rows[0].querySelectorAll("label")[1].getAttribute("value"), "11.1foo",
+ "The second column of the first row displays the correct text.");
+
+ ok(rows[1].querySelector(".table-chart-row-box.chart-colored-blob"),
+ "A colored blob exists for the second row.");
+ is(rows[1].querySelectorAll("label")[0].getAttribute("name"), "label1",
+ "The first column of the second row exists.");
+ is(rows[1].querySelectorAll("label")[1].getAttribute("name"), "label2",
+ "The second column of the second row exists.");
+ is(rows[1].querySelectorAll("label")[0].getAttribute("value"), "2",
+ "The first column of the second row displays the correct text.");
+ is(rows[1].querySelectorAll("label")[1].getAttribute("value"), "12.2bar",
+ "The second column of the first row displays the correct text.");
+
+ ok(rows[2].querySelector(".table-chart-row-box.chart-colored-blob"),
+ "A colored blob exists for the third row.");
+ is(rows[2].querySelectorAll("label")[0].getAttribute("name"), "label1",
+ "The first column of the third row exists.");
+ is(rows[2].querySelectorAll("label")[1].getAttribute("name"), "label2",
+ "The second column of the third row exists.");
+ is(rows[2].querySelectorAll("label")[0].getAttribute("value"), "3",
+ "The first column of the third row displays the correct text.");
+ is(rows[2].querySelectorAll("label")[1].getAttribute("value"), "13.3baz",
+ "The second column of the third row displays the correct text.");
+
+ is(sums.length, 2,
+ "There should be 2 total summaries created.");
+
+ is(totals.querySelectorAll(".table-chart-summary-label")[0].getAttribute("name"), "label1",
+ "The first sum's type is correct.");
+ is(totals.querySelectorAll(".table-chart-summary-label")[0].getAttribute("value"), "Hello 6",
+ "The first sum's value is correct.");
+
+ is(totals.querySelectorAll(".table-chart-summary-label")[1].getAttribute("name"), "label2",
+ "The second sum's type is correct.");
+ is(totals.querySelectorAll(".table-chart-summary-label")[1].getAttribute("value"), "World 36.60",
+ "The second sum's value is correct.");
+
+ teardown(aMonitor).then(finish);
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_charts-04.js b/browser/devtools/netmonitor/test/browser_net_charts-04.js
new file mode 100644
index 000000000000..e9d247b33787
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_charts-04.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Makes sure Pie Charts have the right internal structure when
+ * initialized with empty data.
+ */
+
+function test() {
+ initNetMonitor(SIMPLE_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let { document, L10N, Chart } = aMonitor.panelWin;
+ let container = document.createElement("box");
+
+ let table = Chart.Table(document, {
+ title: "Table title",
+ data: null,
+ totals: {
+ label1: "Hello %S",
+ label2: "World %S"
+ }
+ });
+
+ let node = table.node;
+ let title = node.querySelector(".table-chart-title");
+ let grid = node.querySelector(".table-chart-grid");
+ let totals = node.querySelector(".table-chart-totals");
+ let rows = grid.querySelectorAll(".table-chart-row");
+ let sums = node.querySelectorAll(".table-chart-summary-label");
+
+ ok(node.classList.contains("table-chart-container") &&
+ node.classList.contains("generic-chart-container"),
+ "A table chart container was created successfully.");
+
+ ok(title,
+ "A title node was created successfully.");
+ is(title.getAttribute("value"), "Table title",
+ "The title node displays the correct text.");
+
+ is(rows.length, 1,
+ "There should be 1 table chart row created.");
+
+ ok(rows[0].querySelector(".table-chart-row-box.chart-colored-blob"),
+ "A colored blob exists for the firt row.");
+ is(rows[0].querySelectorAll("label")[0].getAttribute("name"), "size",
+ "The first column of the first row exists.");
+ is(rows[0].querySelectorAll("label")[1].getAttribute("name"), "label",
+ "The second column of the first row exists.");
+ is(rows[0].querySelectorAll("label")[0].getAttribute("value"), "",
+ "The first column of the first row displays the correct text.");
+ is(rows[0].querySelectorAll("label")[1].getAttribute("value"), L10N.getStr("tableChart.empty"),
+ "The second column of the first row displays the correct text.");
+
+ is(sums.length, 2,
+ "There should be 2 total summaries created.");
+
+ is(totals.querySelectorAll(".table-chart-summary-label")[0].getAttribute("name"), "label1",
+ "The first sum's type is correct.");
+ is(totals.querySelectorAll(".table-chart-summary-label")[0].getAttribute("value"), "Hello 0",
+ "The first sum's value is correct.");
+
+ is(totals.querySelectorAll(".table-chart-summary-label")[1].getAttribute("name"), "label2",
+ "The second sum's type is correct.");
+ is(totals.querySelectorAll(".table-chart-summary-label")[1].getAttribute("value"), "World 0",
+ "The second sum's value is correct.");
+
+ teardown(aMonitor).then(finish);
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_charts-05.js b/browser/devtools/netmonitor/test/browser_net_charts-05.js
new file mode 100644
index 000000000000..b118d3a3601a
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_charts-05.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Makes sure Pie+Table Charts have the right internal structure.
+ */
+
+function test() {
+ initNetMonitor(SIMPLE_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let { document, Chart } = aMonitor.panelWin;
+ let container = document.createElement("box");
+
+ let chart = Chart.PieTable(document, {
+ title: "Table title",
+ data: [{
+ size: 1,
+ label: "11.1foo"
+ }, {
+ size: 2,
+ label: "12.2bar"
+ }, {
+ size: 3,
+ label: "13.3baz"
+ }],
+ totals: {
+ size: "Hello %S",
+ label: "World %S"
+ }
+ });
+
+ ok(chart.pie, "The pie chart proxy is accessible.");
+ ok(chart.table, "The table chart proxy is accessible.");
+
+ let node = chart.node;
+ let slices = node.querySelectorAll(".pie-chart-slice");
+ let rows = node.querySelectorAll(".table-chart-row");
+ let sums = node.querySelectorAll(".table-chart-summary-label");
+
+ ok(node.classList.contains("pie-table-chart-container"),
+ "A pie+table chart container was created successfully.");
+
+ ok(node.querySelector(".table-chart-title"),
+ "A title node was created successfully.");
+ ok(node.querySelector(".pie-chart-container"),
+ "A pie chart was created successfully.");
+ ok(node.querySelector(".table-chart-container"),
+ "A table chart was created successfully.");
+
+ is(rows.length, 3,
+ "There should be 3 pie chart slices created.");
+ is(rows.length, 3,
+ "There should be 3 table chart rows created.");
+ is(sums.length, 2,
+ "There should be 2 total summaries created.");
+
+ teardown(aMonitor).then(finish);
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_footer-summary.js b/browser/devtools/netmonitor/test/browser_net_footer-summary.js
index 4ef217f530ca..6a87943e76d7 100644
--- a/browser/devtools/netmonitor/test/browser_net_footer-summary.js
+++ b/browser/devtools/netmonitor/test/browser_net_footer-summary.js
@@ -80,7 +80,7 @@ function test() {
})
function testStatus() {
- let summary = $("#request-menu-network-summary");
+ let summary = $("#requests-menu-network-summary-label");
let value = summary.getAttribute("value");
info("Current summary: " + value);
@@ -89,13 +89,7 @@ function test() {
let totalRequestsCount = RequestsMenu.itemCount;
info("Current requests: " + visibleRequestsCount + " of " + totalRequestsCount + ".");
- if (!totalRequestsCount) {
- is(value, "",
- "The current summary text is incorrect, expected an empty string.");
- return;
- }
-
- if (!visibleRequestsCount) {
+ if (!totalRequestsCount || !visibleRequestsCount) {
is(value, L10N.getStr("networkMenu.empty"),
"The current summary text is incorrect, expected an 'empty' label.");
return;
diff --git a/browser/devtools/netmonitor/test/browser_net_statistics-01.js b/browser/devtools/netmonitor/test/browser_net_statistics-01.js
new file mode 100644
index 000000000000..2c7da1cc8d87
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_statistics-01.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the statistics view is populated correctly.
+ */
+
+function test() {
+ initNetMonitor(STATISTICS_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let panel = aMonitor.panelWin;
+ let { document, $, $all, EVENTS, NetMonitorView } = panel;
+
+ is(NetMonitorView.currentFrontendMode, "network-inspector-view",
+ "The initial frontend mode is correct.");
+
+ is($("#primed-cache-chart").childNodes.length, 0,
+ "There should be no primed cache chart created yet.");
+ is($("#empty-cache-chart").childNodes.length, 0,
+ "There should be no empty cache chart created yet.");
+
+ waitFor(panel, EVENTS.PLACEHOLDER_CHARTS_DISPLAYED).then(() => {
+ is($("#primed-cache-chart").childNodes.length, 1,
+ "There should be a placeholder primed cache chart created now.");
+ is($("#empty-cache-chart").childNodes.length, 1,
+ "There should be a placeholder empty cache chart created now.");
+
+ is($all(".pie-chart-container[placeholder=true]").length, 2,
+ "Two placeholder pie chart appear to be rendered correctly.");
+ is($all(".table-chart-container[placeholder=true]").length, 2,
+ "Two placeholder table chart appear to be rendered correctly.");
+
+ promise.all([
+ waitFor(panel, EVENTS.PRIMED_CACHE_CHART_DISPLAYED),
+ waitFor(panel, EVENTS.EMPTY_CACHE_CHART_DISPLAYED)
+ ]).then(() => {
+ is($("#primed-cache-chart").childNodes.length, 1,
+ "There should be a real primed cache chart created now.");
+ is($("#empty-cache-chart").childNodes.length, 1,
+ "There should be a real empty cache chart created now.");
+
+ is($all(".pie-chart-container:not([placeholder=true])").length, 2,
+ "Two real pie chart appear to be rendered correctly.");
+ is($all(".table-chart-container:not([placeholder=true])").length, 2,
+ "Two real table chart appear to be rendered correctly.");
+
+ teardown(aMonitor).then(finish);
+ });
+ });
+
+ NetMonitorView.toggleFrontendMode();
+
+ is(NetMonitorView.currentFrontendMode, "network-statistics-view",
+ "The current frontend mode is correct.");
+ });
+}
diff --git a/browser/devtools/netmonitor/test/browser_net_statistics-02.js b/browser/devtools/netmonitor/test/browser_net_statistics-02.js
new file mode 100644
index 000000000000..b43e9a0e8960
--- /dev/null
+++ b/browser/devtools/netmonitor/test/browser_net_statistics-02.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests if the network inspector view is shown when the target navigates
+ * away while in the statistics view.
+ */
+
+function test() {
+ initNetMonitor(STATISTICS_URL).then(([aTab, aDebuggee, aMonitor]) => {
+ info("Starting test... ");
+
+ let panel = aMonitor.panelWin;
+ let { document, EVENTS, NetMonitorView } = panel;
+
+ is(NetMonitorView.currentFrontendMode, "network-inspector-view",
+ "The initial frontend mode is correct.");
+
+ promise.all([
+ waitFor(panel, EVENTS.PRIMED_CACHE_CHART_DISPLAYED),
+ waitFor(panel, EVENTS.EMPTY_CACHE_CHART_DISPLAYED)
+ ]).then(() => {
+ is(NetMonitorView.currentFrontendMode, "network-statistics-view",
+ "The frontend mode is currently in the statistics view.");
+
+ waitFor(panel, EVENTS.TARGET_WILL_NAVIGATE).then(() => {
+ is(NetMonitorView.currentFrontendMode, "network-inspector-view",
+ "The frontend mode switched back to the inspector view.");
+
+ waitFor(panel, EVENTS.TARGET_DID_NAVIGATE).then(() => {
+ is(NetMonitorView.currentFrontendMode, "network-inspector-view",
+ "The frontend mode is still in the inspector view.");
+
+ teardown(aMonitor).then(finish);
+ });
+ });
+
+ aDebuggee.location.reload();
+ });
+
+ NetMonitorView.toggleFrontendMode();
+ });
+}
diff --git a/browser/devtools/netmonitor/test/head.js b/browser/devtools/netmonitor/test/head.js
index 0fa11ec0704e..5d0797a97df6 100644
--- a/browser/devtools/netmonitor/test/head.js
+++ b/browser/devtools/netmonitor/test/head.js
@@ -29,6 +29,7 @@ const SORTING_URL = EXAMPLE_URL + "html_sorting-test-page.html";
const FILTERING_URL = EXAMPLE_URL + "html_filter-test-page.html";
const INFINITE_GET_URL = EXAMPLE_URL + "html_infinite-get-page.html";
const CUSTOM_GET_URL = EXAMPLE_URL + "html_custom-get-page.html";
+const STATISTICS_URL = EXAMPLE_URL + "html_statistics-test-page.html";
const SIMPLE_SJS = EXAMPLE_URL + "sjs_simple-test-server.sjs";
const CONTENT_TYPE_SJS = EXAMPLE_URL + "sjs_content-type-test-server.sjs";
diff --git a/browser/devtools/netmonitor/test/html_statistics-test-page.html b/browser/devtools/netmonitor/test/html_statistics-test-page.html
new file mode 100644
index 000000000000..37b57f1340d8
--- /dev/null
+++ b/browser/devtools/netmonitor/test/html_statistics-test-page.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+ Network Monitor test page
+
+
+
+ Statistics test
+
+
+
+
+
diff --git a/browser/devtools/netmonitor/test/sjs_content-type-test-server.sjs b/browser/devtools/netmonitor/test/sjs_content-type-test-server.sjs
index f478fdddf847..088059d5724c 100644
--- a/browser/devtools/netmonitor/test/sjs_content-type-test-server.sjs
+++ b/browser/devtools/netmonitor/test/sjs_content-type-test-server.sjs
@@ -7,118 +7,151 @@ function handleRequest(request, response) {
response.processAsync();
let params = request.queryString.split("&");
- let format = params.filter((s) => s.contains("fmt="))[0].split("=")[1];
+ let format = (params.filter((s) => s.contains("fmt="))[0] || "").split("=")[1];
+ let status = (params.filter((s) => s.contains("sts="))[0] || "").split("=")[1] || 200;
+
+ let cachedCount = 0;
+ let cacheExpire = 60; // seconds
+
+ function maybeMakeCached() {
+ if (status != 304) {
+ return;
+ }
+ // Spice things up a little!
+ if (cachedCount % 2) {
+ response.setHeader("Cache-Control", "max-age=" + cacheExpire, false);
+ } else {
+ response.setHeader("Expires", Date(Date.now() + cacheExpire * 1000), false);
+ }
+ cachedCount++;
+ }
Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer).initWithCallback(() => {
switch (format) {
case "txt": {
- response.setStatusLine(request.httpVersion, 200, "DA DA DA");
+ response.setStatusLine(request.httpVersion, status, "DA DA DA");
response.setHeader("Content-Type", "text/plain", false);
+ maybeMakeCached();
response.write("Братан, ты вообще качаешься?");
response.finish();
break;
}
case "xml": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/xml; charset=utf-8", false);
+ maybeMakeCached();
response.write("");
response.finish();
break;
}
case "html": {
let content = params.filter((s) => s.contains("res="))[0].split("=")[1];
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ maybeMakeCached();
response.write(content || "Hello HTML!
");
response.finish();
break;
}
case "html-long": {
let str = new Array(102400 /* 100 KB in bytes */).join(".");
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/html; charset=utf-8", false);
+ maybeMakeCached();
response.write("" + str + "
");
response.finish();
break;
}
case "css": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/css; charset=utf-8", false);
+ maybeMakeCached();
response.write("body:pre { content: 'Hello CSS!' }");
response.finish();
break;
}
case "js": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "application/javascript; charset=utf-8", false);
+ maybeMakeCached();
response.write("function() { return 'Hello JS!'; }");
response.finish();
break;
}
case "json": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "application/json; charset=utf-8", false);
+ maybeMakeCached();
response.write("{ \"greeting\": \"Hello JSON!\" }");
response.finish();
break;
}
case "jsonp": {
let fun = params.filter((s) => s.contains("jsonp="))[0].split("=")[1];
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/json; charset=utf-8", false);
+ maybeMakeCached();
response.write(fun + "({ \"greeting\": \"Hello JSONP!\" })");
response.finish();
break;
}
case "json-long": {
let str = "{ \"greeting\": \"Hello long string JSON!\" },";
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/json; charset=utf-8", false);
+ maybeMakeCached();
response.write("[" + new Array(2048).join(str).slice(0, -1) + "]");
response.finish();
break;
}
case "json-malformed": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/json; charset=utf-8", false);
+ maybeMakeCached();
response.write("{ \"greeting\": \"Hello malformed JSON!\" },");
response.finish();
break;
}
case "json-custom-mime": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "text/x-bigcorp-json; charset=utf-8", false);
+ maybeMakeCached();
response.write("{ \"greeting\": \"Hello oddly-named JSON!\" }");
response.finish();
break;
}
case "font": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "font/woff", false);
+ maybeMakeCached();
response.finish();
break;
}
case "image": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "image/png", false);
+ maybeMakeCached();
response.finish();
break;
}
case "audio": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "audio/ogg", false);
+ maybeMakeCached();
response.finish();
break;
}
case "video": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "video/webm", false);
+ maybeMakeCached();
response.finish();
break;
}
case "flash": {
- response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setStatusLine(request.httpVersion, status, "OK");
response.setHeader("Content-Type", "application/x-shockwave-flash", false);
+ maybeMakeCached();
response.finish();
break;
}
diff --git a/browser/devtools/shared/widgets/Chart.jsm b/browser/devtools/shared/widgets/Chart.jsm
new file mode 100644
index 000000000000..a47e0de85f69
--- /dev/null
+++ b/browser/devtools/shared/widgets/Chart.jsm
@@ -0,0 +1,422 @@
+/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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 Ci = Components.interfaces;
+const Cu = Components.utils;
+
+const NET_STRINGS_URI = "chrome://browser/locale/devtools/netmonitor.properties";
+const SVG_NS = "http://www.w3.org/2000/svg";
+const PI = Math.PI;
+const TAU = PI * 2;
+const EPSILON = 0.0000001;
+const NAMED_SLICE_MIN_ANGLE = TAU / 8;
+const NAMED_SLICE_TEXT_DISTANCE_RATIO = 1.9;
+const HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO = 20;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
+Cu.import("resource:///modules/devtools/shared/event-emitter.js");
+
+this.EXPORTED_SYMBOLS = ["Chart"];
+
+/**
+ * Localization convenience methods.
+ */
+let L10N = new ViewHelpers.L10N(NET_STRINGS_URI);
+
+/**
+ * A factory for creating charts.
+ * Example usage: let myChart = Chart.Pie(document, { ... });
+ */
+let Chart = {
+ Pie: createPieChart,
+ Table: createTableChart,
+ PieTable: createPieTableChart
+};
+
+/**
+ * A simple pie chart proxy for the underlying view.
+ * Each item in the `slices` property represents a [data, node] pair containing
+ * the data used to create the slice and the nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ */
+function PieChart(node) {
+ this.node = node;
+ this.slices = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple table chart proxy for the underlying view.
+ * Each item in the `rows` property represents a [data, node] pair containing
+ * the data used to create the row and the nsIDOMNode displaying it.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ */
+function TableChart(node) {
+ this.node = node;
+ this.rows = new WeakMap();
+ EventEmitter.decorate(this);
+}
+
+/**
+ * A simple pie+table chart proxy for the underlying view.
+ *
+ * @param nsIDOMNode node
+ * The node representing the view for this chart.
+ * @param PieChart pie
+ * The pie chart proxy.
+ * @param TableChart table
+ * The table chart proxy.
+ */
+function PieTableChart(node, pie, table) {
+ this.node = node;
+ this.pie = pie;
+ this.table = table;
+ EventEmitter.decorate(this);
+}
+
+/**
+ * Creates the DOM for a pie+table chart.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the table chart's (description)/local
+ * - diameter: the diameter of the pie chart, in pixels
+ * - data: an array of items used to display each slice in the pie
+ * and each row in the table;
+ * @see `createPieChart` and `createTableChart` for details.
+ * - sorted: a flag specifying if the `data` should be sorted
+ * ascending by `size`.
+ * - totals: @see `createTableChart` for details.
+ * @return PieTableChart
+ * A pie+table chart proxy instance, which emits the following events:
+ * - "mouseenter", when the mouse enters a slice or a row
+ * - "mouseleave", when the mouse leaves a slice or a row
+ * - "click", when the mouse enters a slice or a row
+ */
+function createPieTableChart(document, { sorted, title, diameter, data, totals }) {
+ if (sorted) {
+ data = data.slice().sort((a, b) => +(parseFloat(a.size) < parseFloat(b.size)));
+ }
+
+ let pie = Chart.Pie(document, {
+ width: diameter,
+ data: data
+ });
+
+ let table = Chart.Table(document, {
+ title: title,
+ data: data,
+ totals: totals
+ });
+
+ let container = document.createElement("hbox");
+ container.className = "pie-table-chart-container";
+ container.appendChild(pie.node);
+ container.appendChild(table.node);
+
+ let proxy = new PieTableChart(container, pie, table);
+
+ pie.on("click", (event, item) => {
+ proxy.emit(event, item)
+ });
+
+ table.on("click", (event, item) => {
+ proxy.emit(event, item)
+ });
+
+ pie.on("mouseenter", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).setAttribute("focused", "");
+ }
+ });
+
+ pie.on("mouseleave", (event, item) => {
+ proxy.emit(event, item);
+ if (table.rows.has(item)) {
+ table.rows.get(item).removeAttribute("focused");
+ }
+ });
+
+ table.on("mouseenter", (event, item) => {
+ proxy.emit(event, item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).setAttribute("focused", "");
+ }
+ });
+
+ table.on("mouseleave", (event, item) => {
+ proxy.emit(event, item);
+ if (pie.slices.has(item)) {
+ pie.slices.get(item).removeAttribute("focused");
+ }
+ });
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a pie chart based on the specified properties.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - data: an array of items used to display each slice; all the items
+ * should be objects containing a `size` and a `label` property.
+ * e.g: [{
+ * size: 1,
+ * label: "foo"
+ * }, {
+ * size: 2,
+ * label: "bar"
+ * }];
+ * - width: the width of the chart, in pixels
+ * - height: optional, the height of the chart, in pixels.
+ * - centerX: optional, the X-axis center of the chart, in pixels.
+ * - centerY: optional, the Y-axis center of the chart, in pixels.
+ * - radius: optional, the radius of the chart, in pixels.
+ * @return PieChart
+ * A pie chart proxy instance, which emits the following events:
+ * - "mouseenter", when the mouse enters a slice
+ * - "mouseleave", when the mouse leaves a slice
+ * - "click", when the mouse clicks a slice
+ */
+function createPieChart(document, { data, width, height, centerX, centerY, radius }) {
+ height = height || width;
+ centerX = centerX || width / 2;
+ centerY = centerY || height / 2;
+ radius = radius || (width + height) / 4;
+ let isPlaceholder = false;
+
+ // Filter out very small sizes, as they'll just render invisible slices.
+ data = data ? data.filter(e => parseFloat(e.size) > EPSILON) : null;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data || !data.length) {
+ data = emptyPieChartData;
+ isPlaceholder = true;
+ }
+
+ let container = document.createElementNS(SVG_NS, "svg");
+ container.setAttribute("class", "generic-chart-container pie-chart-container");
+ container.setAttribute("pack", "center");
+ container.setAttribute("flex", "1");
+ container.setAttribute("width", width);
+ container.setAttribute("height", height);
+ container.setAttribute("viewBox", "0 0 " + width + " " + height);
+ container.setAttribute("slices", data.length);
+ container.setAttribute("placeholder", isPlaceholder);
+
+ let proxy = new PieChart(container);
+
+ let total = data.reduce((acc, e) => acc + parseFloat(e.size), 0);
+ let angles = data.map(e => parseFloat(e.size) / total * (TAU - EPSILON));
+ let largest = data.reduce((a, b) => parseFloat(a.size) > parseFloat(b.size) ? a : b);
+ let smallest = data.reduce((a, b) => parseFloat(a.size) < parseFloat(b.size) ? a : b);
+
+ let textDistance = radius / NAMED_SLICE_TEXT_DISTANCE_RATIO;
+ let translateDistance = radius / HOVERED_SLICE_TRANSLATE_DISTANCE_RATIO;
+ let startAngle = TAU;
+ let endAngle = 0;
+ let midAngle = 0;
+ radius -= translateDistance;
+
+ for (let i = data.length - 1; i >= 0; i--) {
+ let sliceInfo = data[i];
+ let sliceAngle = angles[i];
+ if (!sliceInfo.size || sliceAngle < EPSILON) {
+ continue;
+ }
+
+ endAngle = startAngle - sliceAngle;
+ midAngle = (startAngle + endAngle) / 2;
+
+ let x1 = centerX + radius * Math.sin(startAngle);
+ let y1 = centerY - radius * Math.cos(startAngle);
+ let x2 = centerX + radius * Math.sin(endAngle);
+ let y2 = centerY - radius * Math.cos(endAngle);
+ let largeArcFlag = Math.abs(startAngle - endAngle) > PI ? 1 : 0;
+
+ let pathNode = document.createElementNS(SVG_NS, "path");
+ pathNode.setAttribute("class", "pie-chart-slice chart-colored-blob");
+ pathNode.setAttribute("name", sliceInfo.label);
+ pathNode.setAttribute("d",
+ " M " + centerX + "," + centerY +
+ " L " + x2 + "," + y2 +
+ " A " + radius + "," + radius +
+ " 0 " + largeArcFlag +
+ " 1 " + x1 + "," + y1 +
+ " Z");
+
+ if (sliceInfo == largest) {
+ pathNode.setAttribute("largest", "");
+ }
+ if (sliceInfo == smallest) {
+ pathNode.setAttribute("smallest", "");
+ }
+
+ let hoverX = translateDistance * Math.sin(midAngle);
+ let hoverY = -translateDistance * Math.cos(midAngle);
+ let hoverTransform = "transform: translate(" + hoverX + "px, " + hoverY + "px)";
+ pathNode.setAttribute("style", hoverTransform);
+
+ proxy.slices.set(sliceInfo, pathNode);
+ delegate(proxy, ["click", "mouseenter", "mouseleave"], pathNode, sliceInfo);
+ container.appendChild(pathNode);
+
+ if (sliceInfo.label && sliceAngle > NAMED_SLICE_MIN_ANGLE) {
+ let textX = centerX + textDistance * Math.sin(midAngle);
+ let textY = centerY - textDistance * Math.cos(midAngle);
+ let label = document.createElementNS(SVG_NS, "text");
+ label.appendChild(document.createTextNode(sliceInfo.label));
+ label.setAttribute("class", "pie-chart-label");
+ label.setAttribute("style", data.length > 1 ? hoverTransform : "");
+ label.setAttribute("x", data.length > 1 ? textX : centerX);
+ label.setAttribute("y", data.length > 1 ? textY : centerY);
+ container.appendChild(label);
+ }
+
+ startAngle = endAngle;
+ }
+
+ return proxy;
+}
+
+/**
+ * Creates the DOM for a table chart based on the specified properties.
+ *
+ * @param nsIDocument document
+ * The document responsible with creating the DOM.
+ * @param object
+ * An object containing all or some of the following properties:
+ * - title: a string displayed as the chart's (description)/local
+ * - data: an array of items used to display each row; all the items
+ * should be objects representing columns, for which the
+ * properties' values will be displayed in each cell of a row.
+ * e.g: [{
+ * size: 1,
+ * label2: "1foo",
+ * label3: "2yolo"
+ * }, {
+ * size: 2,
+ * label2: "3bar",
+ * label3: "4swag"
+ * }];
+ * - totals: an object specifying for which rows in the `data` array
+ * the sum of their cells is to be displayed in the chart;
+ * e.g: {
+ * label1: "Total size: %S",
+ * label3: "Total lolz: %S"
+ * }
+ * @return TableChart
+ * A table chart proxy instance, which emits the following events:
+ * - "mouseenter", when the mouse enters a row
+ * - "mouseleave", when the mouse leaves a row
+ * - "click", when the mouse clicks a row
+ */
+function createTableChart(document, { data, totals, title }) {
+ let isPlaceholder = false;
+
+ // If there's no data available, display an empty placeholder.
+ if (!data || !data.length) {
+ data = emptyTableChartData;
+ isPlaceholder = true;
+ }
+
+ let container = document.createElement("vbox");
+ container.className = "generic-chart-container table-chart-container";
+ container.setAttribute("pack", "center");
+ container.setAttribute("flex", "1");
+ container.setAttribute("rows", data.length);
+ container.setAttribute("placeholder", isPlaceholder);
+
+ let proxy = new TableChart(container);
+
+ let titleNode = document.createElement("label");
+ titleNode.className = "plain table-chart-title";
+ titleNode.setAttribute("value", title);
+ container.appendChild(titleNode);
+
+ let tableNode = document.createElement("vbox");
+ tableNode.className = "plain table-chart-grid";
+ container.appendChild(tableNode);
+
+ for (let rowInfo of data) {
+ let rowNode = document.createElement("hbox");
+ rowNode.className = "table-chart-row";
+ rowNode.setAttribute("align", "center");
+
+ let boxNode = document.createElement("hbox");
+ boxNode.className = "table-chart-row-box chart-colored-blob";
+ boxNode.setAttribute("name", rowInfo.label);
+ rowNode.appendChild(boxNode);
+
+ for (let [key, value] in Iterator(rowInfo)) {
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-row-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", value);
+ rowNode.appendChild(labelNode);
+ }
+
+ proxy.rows.set(rowInfo, rowNode);
+ delegate(proxy, ["click", "mouseenter", "mouseleave"], rowNode, rowInfo);
+ tableNode.appendChild(rowNode);
+ }
+
+ let totalsNode = document.createElement("vbox");
+ totalsNode.className = "table-chart-totals";
+
+ for (let [key, value] in Iterator(totals || {})) {
+ let total = data.reduce((acc, e) => acc + parseFloat(e[key]), 0);
+ let formatted = !isNaN(total) ? L10N.numberWithDecimals(total, 2) : 0;
+ let labelNode = document.createElement("label");
+ labelNode.className = "plain table-chart-summary-label";
+ labelNode.setAttribute("name", key);
+ labelNode.setAttribute("value", value.replace("%S", formatted));
+ totalsNode.appendChild(labelNode);
+ }
+
+ container.appendChild(totalsNode);
+
+ return proxy;
+}
+
+XPCOMUtils.defineLazyGetter(this, "emptyPieChartData", () => {
+ return [{ size: 1, label: L10N.getStr("pieChart.empty") }];
+});
+
+XPCOMUtils.defineLazyGetter(this, "emptyTableChartData", () => {
+ return [{ size: "", label: L10N.getStr("tableChart.empty") }];
+});
+
+/**
+ * Delegates DOM events emitted by an nsIDOMNode to an EventEmitter proxy.
+ *
+ * @param EventEmitter emitter
+ * The event emitter proxy instance.
+ * @param array events
+ * An array of events, e.g. ["mouseenter", "mouseleave"].
+ * @param nsIDOMNode node
+ * The element firing the DOM events.
+ * @param any args
+ * The arguments passed when emitting events through the proxy.
+ */
+function delegate(emitter, events, node, args) {
+ for (let event of events) {
+ node.addEventListener(event, emitter.emit.bind(emitter, event, args));
+ }
+}
diff --git a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
index 4e3f1963b3ee..a451186856f4 100644
--- a/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
+++ b/browser/locales/en-US/chrome/browser/devtools/netmonitor.dtd
@@ -11,9 +11,14 @@
- A good criteria is the language in which you'd find the best
- documentation on web development on the web. -->
-
+
+
+
+
-
+
@@ -99,10 +104,18 @@
- in the network details footer for the "Flash" filtering button. -->
+
+
+
+
+
+
@@ -173,22 +186,30 @@
- in a "receive" state. -->
+
+
+
+
+
+
-
+
-
+
-
+
-
+
-
+
@@ -223,3 +244,7 @@
+
+
+
diff --git a/browser/locales/en-US/chrome/browser/devtools/netmonitor.properties b/browser/locales/en-US/chrome/browser/devtools/netmonitor.properties
index ae6224e50545..74d83f192da0 100644
--- a/browser/locales/en-US/chrome/browser/devtools/netmonitor.properties
+++ b/browser/locales/en-US/chrome/browser/devtools/netmonitor.properties
@@ -135,3 +135,49 @@ networkMenu.second=%S s
# LOCALIZATION NOTE (networkMenu.minute): This is the label displayed
# in the network menu specifying timing interval divisions (in minutes).
networkMenu.minute=%S min
+
+# LOCALIZATION NOTE (networkMenu.minute): This is the label displayed
+# in the network menu specifying timing interval divisions (in minutes).
+networkMenu.minute=%S min
+
+# LOCALIZATION NOTE (pieChart.empty): This is the label displayed
+# for pie charts (e.g., in the performance analysis view) when there is
+# no data available yet.
+pieChart.empty=Loading
+
+# LOCALIZATION NOTE (tableChart.empty): This is the label displayed
+# for table charts (e.g., in the performance analysis view) when there is
+# no data available yet.
+tableChart.empty=Please wait…
+
+# LOCALIZATION NOTE (charts.sizeKB): This is the label displayed
+# in pie or table charts specifying the size of a request (in kilobytes).
+charts.sizeKB=%S KB
+
+# LOCALIZATION NOTE (charts.totalMS): This is the label displayed
+# in pie or table charts specifying the time for a request to finish (in milliseconds).
+charts.totalMS=%S ms
+
+# LOCALIZATION NOTE (charts.cacheEnabled): This is the label displayed
+# in the performance analysis view for "cache enabled" charts.
+charts.cacheEnabled=Primed cache
+
+# LOCALIZATION NOTE (charts.cacheDisabled): This is the label displayed
+# in the performance analysis view for "cache disabled" charts.
+charts.cacheDisabled=Empty cache
+
+# LOCALIZATION NOTE (charts.totalSize): This is the label displayed
+# in the performance analysis view for total requests size, in kilobytes.
+charts.totalSize=Size: %S KB
+
+# LOCALIZATION NOTE (charts.totalTime): This is the label displayed
+# in the performance analysis view for total requests time, in milliseconds.
+charts.totalTime=Time: %S ms
+
+# LOCALIZATION NOTE (charts.totalCached): This is the label displayed
+# in the performance analysis view for total cached responses.
+charts.totalCached=Cached responses: %S
+
+# LOCALIZATION NOTE (charts.totalCount): This is the label displayed
+# in the performance analysis view for total requests.
+charts.totalCount=Total requests: %S
diff --git a/browser/themes/shared/devtools/netmonitor.inc.css b/browser/themes/shared/devtools/netmonitor.inc.css
index 763a73d20978..f8d4e242461b 100644
--- a/browser/themes/shared/devtools/netmonitor.inc.css
+++ b/browser/themes/shared/devtools/netmonitor.inc.css
@@ -6,7 +6,7 @@
#requests-menu-empty-notice {
margin: 0;
padding: 12px;
- font-size: 110%;
+ font-size: 120%;
}
.theme-dark #requests-menu-empty-notice {
@@ -17,6 +17,18 @@
color: #585959; /* Grey foreground text */
}
+#requests-menu-perf-notice-button {
+ min-width: 30px;
+ min-height: 28px;
+ margin: 0;
+ list-style-image: url(profiler-stopwatch.png);
+ -moz-image-region: rect(0px,16px,16px,0px);
+}
+
+#requests-menu-perf-notice-button .button-text {
+ display: none;
+}
+
%filter substitution
%define table_itemDarkStartBorder rgba(0,0,0,0.2)
%define table_itemDarkEndBorder rgba(128,128,128,0.15)
@@ -475,12 +487,11 @@ box.requests-menu-status {
/* Network request details tabpanels */
.theme-dark .tabpanel-content {
+ background: url(background-noise-toolbar.png), #343c45; /* Toolbars */
color: #f5f7fa; /* Light foreground text */
}
-.theme-dark .tabpanel-summary-label {
- color: #f5f7fa; /* Dark foreground text */
-}
+/* Summary tabpanel */
.tabpanel-summary-container {
padding: 1px;
@@ -578,7 +589,7 @@ box.requests-menu-status {
min-width: 1em;
margin: 0;
border: none;
- padding: 2px 1.5vw;
+ padding: 2px 0.75vw;
}
.theme-dark .requests-menu-footer-button,
@@ -595,14 +606,14 @@ box.requests-menu-status {
min-width: 2px;
}
-.theme-dark .requests-menu-footer-spacer:not(:first-of-type),
-.theme-dark .requests-menu-footer-button:not(:first-of-type) {
+.theme-dark .requests-menu-footer-spacer:not(:first-child),
+.theme-dark .requests-menu-footer-button:not(:first-child) {
-moz-border-start: 1px solid @table_itemDarkStartBorder@;
box-shadow: -1px 0 0 @table_itemDarkEndBorder@;
}
-.theme-light .requests-menu-footer-spacer:not(:first-of-type),
-.theme-light .requests-menu-footer-button:not(:first-of-type) {
+.theme-light .requests-menu-footer-spacer:not(:first-child),
+.theme-light .requests-menu-footer-button:not(:first-child) {
-moz-border-start: 1px solid @table_itemLightStartBorder@;
box-shadow: -1px 0 0 @table_itemLightEndBorder@;
}
@@ -628,10 +639,179 @@ box.requests-menu-status {
}
.requests-menu-footer-label {
- padding-top: 2px;
+ padding-top: 3px;
font-weight: 600;
}
+/* Performance analysis buttons */
+
+#requests-menu-network-summary-button {
+ background: none;
+ box-shadow: none;
+ border-color: transparent;
+ list-style-image: url(profiler-stopwatch.png);
+ -moz-image-region: rect(0px,16px,16px,0px);
+ -moz-padding-end: 0;
+ cursor: pointer;
+}
+
+#requests-menu-network-summary-label {
+ -moz-padding-start: 0;
+ cursor: pointer;
+}
+
+#requests-menu-network-summary-label:hover {
+ text-decoration: underline;
+}
+
+/* Performance analysis view */
+
+#network-statistics-toolbar {
+ border: none;
+ margin: 0;
+ padding: 0;
+}
+
+#network-statistics-back-button {
+ min-width: 4em;
+ min-height: 100vh;
+ margin: 0;
+ padding: 0;
+ border-radius: 0;
+ border-top: none;
+ border-bottom: none;
+ -moz-border-start: none;
+}
+
+#network-statistics-view-splitter {
+ border-color: rgba(0,0,0,0.2);
+ cursor: default;
+ pointer-events: none;
+}
+
+#network-statistics-charts {
+ min-height: 1px;
+}
+
+.theme-dark #network-statistics-charts {
+ background: url(background-noise-toolbar.png), #343c45; /* Toolbars */
+}
+
+.theme-light #network-statistics-charts {
+ background: url(background-noise-toolbar.png), #f0f1f2; /* Toolbars */
+}
+
+#network-statistics-charts .pie-chart-container {
+ -moz-margin-start: 3vw;
+ -moz-margin-end: 1vw;
+}
+
+#network-statistics-charts .table-chart-container {
+ -moz-margin-start: 1vw;
+ -moz-margin-end: 3vw;
+}
+
+.theme-dark .chart-colored-blob[name=html] {
+ fill: #5e88b0; /* Blue-Grey highlight */
+ background: #5e88b0;
+}
+
+.theme-light .chart-colored-blob[name=html] {
+ fill: #5f88b0; /* Blue-Grey highlight */
+ background: #5f88b0;
+}
+
+.theme-dark .chart-colored-blob[name=css] {
+ fill: #46afe3; /* Blue highlight */
+ background: #46afe3;
+}
+
+.theme-light .chart-colored-blob[name=css] {
+ fill: #0088cc; /* Blue highlight */
+ background: #0088cc;
+}
+
+.theme-dark .chart-colored-blob[name=js] {
+ fill: #d99b28; /* Light Orange highlight */
+ background: #d99b28;
+}
+
+.theme-light .chart-colored-blob[name=js] {
+ fill: #d97e00; /* Light Orange highlight */
+ background: #d97e00;
+}
+
+.theme-dark .chart-colored-blob[name=xhr] {
+ fill: #d96629; /* Orange highlight */
+ background: #d96629;
+}
+
+.theme-light .chart-colored-blob[name=xhr] {
+ fill: #f13c00; /* Orange highlight */
+ background: #f13c00;
+}
+
+.theme-dark .chart-colored-blob[name=fonts] {
+ fill: #6b7abb; /* Purple highlight */
+ background: #6b7abb;
+}
+
+.theme-light .chart-colored-blob[name=fonts] {
+ fill: #5b5fff; /* Purple highlight */
+ background: #5b5fff;
+}
+
+.theme-dark .chart-colored-blob[name=images] {
+ fill: #df80ff; /* Pink highlight */
+ background: #df80ff;
+}
+
+.theme-light .chart-colored-blob[name=images] {
+ fill: #b82ee5; /* Pink highlight */
+ background: #b82ee5;
+}
+
+.theme-dark .chart-colored-blob[name=media] {
+ fill: #70bf53; /* Green highlight */
+ background: #70bf53;
+}
+
+.theme-light .chart-colored-blob[name=media] {
+ fill: #2cbb0f; /* Green highlight */
+ background: #2cbb0f;
+}
+
+.theme-dark .chart-colored-blob[name=flash] {
+ fill: #eb5368; /* Red highlight */
+ background: #eb5368;
+}
+
+.theme-light .chart-colored-blob[name=flash] {
+ fill: #ed2655; /* Red highlight */
+ background: #ed2655;
+}
+
+.table-chart-row-label[name=cached] {
+ display: none;
+}
+
+.table-chart-row-label[name=count] {
+ width: 3em;
+ text-align: end;
+}
+
+.table-chart-row-label[name=label] {
+ width: 7em;
+}
+
+.table-chart-row-label[name=size] {
+ width: 7em;
+}
+
+.table-chart-row-label[name=time] {
+ width: 7em;
+}
+
/* Responsive sidebar */
@media (max-width: 700px) {
#requests-menu-toolbar {
@@ -644,7 +824,7 @@ box.requests-menu-status {
.requests-menu-footer-button,
.requests-menu-footer-label {
- padding: 2px 2vw;
+ padding: 2px 1vw;
}
#details-pane {
diff --git a/browser/themes/shared/devtools/toolbars.inc.css b/browser/themes/shared/devtools/toolbars.inc.css
index 4d5934496397..b01f9a0286ad 100644
--- a/browser/themes/shared/devtools/toolbars.inc.css
+++ b/browser/themes/shared/devtools/toolbars.inc.css
@@ -723,7 +723,9 @@
.theme-light #breadcrumb-separator-normal,
.theme-light .scrollbutton-up > .toolbarbutton-icon,
.theme-light .scrollbutton-down > .toolbarbutton-icon,
-.theme-light #black-boxed-message-button .button-icon {
+.theme-light #black-boxed-message-button .button-icon,
+.theme-light #requests-menu-perf-notice-button .button-icon,
+.theme-light #requests-menu-network-summary-button .button-icon {
filter: url(filters.svg#invert);
}
diff --git a/browser/themes/shared/devtools/widgets.inc.css b/browser/themes/shared/devtools/widgets.inc.css
index c63bf3e6bb7d..0f67ecd6b88b 100644
--- a/browser/themes/shared/devtools/widgets.inc.css
+++ b/browser/themes/shared/devtools/widgets.inc.css
@@ -792,4 +792,145 @@
visibility: hidden;
}
+/* Charts */
+
+.generic-chart-container {
+ /* Hack: force hardware acceleration */
+ transform: translateZ(1px);
+}
+
+.theme-dark .generic-chart-container {
+ color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light .generic-chart-container {
+ color: #585959; /* Grey foreground text */
+}
+
+.theme-dark .chart-colored-blob {
+ fill: #b8c8d9; /* Light content text */
+ background: #b8c8d9;
+}
+
+.theme-light .chart-colored-blob {
+ fill: #8fa1b2; /* Grey content text */
+ background: #8fa1b2;
+}
+
+/* Charts: Pie */
+
+.pie-chart-slice {
+ stroke-width: 1px;
+ cursor: pointer;
+}
+
+.theme-dark .pie-chart-slice {
+ stroke: rgba(0,0,0,0.2);
+}
+
+.theme-light .pie-chart-slice {
+ stroke: rgba(255,255,255,0.8);
+}
+
+.theme-dark .pie-chart-slice[largest] {
+ stroke-width: 2px;
+ stroke: #fff;
+}
+
+.theme-light .pie-chart-slice[largest] {
+ stroke: #000;
+}
+
+.pie-chart-label {
+ text-anchor: middle;
+ dominant-baseline: middle;
+ pointer-events: none;
+}
+
+.theme-dark .pie-chart-label {
+ fill: #000;
+}
+
+.theme-light .pie-chart-label {
+ fill: #fff;
+}
+
+.pie-chart-container[slices="1"] > .pie-chart-slice {
+ stroke-width: 0px;
+}
+
+.pie-chart-slice,
+.pie-chart-label {
+ transition: all 0.1s ease-out;
+}
+
+.pie-chart-slice:not(:hover):not([focused]),
+.pie-chart-slice:not(:hover):not([focused]) + .pie-chart-label {
+ transform: none !important;
+}
+
+/* Charts: Table */
+
+.table-chart-title {
+ padding-bottom: 10px;
+ font-size: 120%;
+ font-weight: 600;
+}
+
+.table-chart-row {
+ margin-top: 1px;
+ cursor: pointer;
+}
+
+.table-chart-grid:hover > .table-chart-row {
+ transition: opacity 0.1s ease-in-out;
+}
+
+.table-chart-grid:not(:hover) > .table-chart-row {
+ transition: opacity 0.2s ease-in-out;
+}
+
+.generic-chart-container:hover > .table-chart-grid:hover > .table-chart-row:not(:hover),
+.generic-chart-container:hover ~ .table-chart-container > .table-chart-grid > .table-chart-row:not([focused]) {
+ opacity: 0.4;
+}
+
+.table-chart-row-box {
+ width: 8px;
+ height: 1.5em;
+ -moz-margin-end: 10px;
+}
+
+.table-chart-row-label {
+ width: 8em;
+ -moz-padding-end: 6px;
+ cursor: inherit;
+}
+
+.table-chart-totals {
+ margin-top: 8px;
+ padding-top: 6px;
+}
+
+.theme-dark .table-chart-totals {
+ border-top: 1px solid #b6babf; /* Grey foreground text */
+}
+
+.theme-light .table-chart-totals {
+ border-top: 1px solid #585959; /* Grey foreground text */
+}
+
+.table-chart-summary-label {
+ font-weight: 600;
+ padding: 1px 0px;
+}
+
+.theme-dark .table-chart-summary-label {
+ color: #f5f7fa; /* Light foreground text */
+}
+
+.theme-light .table-chart-summary-label {
+ color: #18191a; /* Dark foreground text */
+}
+
%include ../../shared/devtools/app-manager/manifest-editor.inc.css
diff --git a/toolkit/devtools/server/actors/webbrowser.js b/toolkit/devtools/server/actors/webbrowser.js
index 53507338f32b..f13b2b91d5b8 100644
--- a/toolkit/devtools/server/actors/webbrowser.js
+++ b/toolkit/devtools/server/actors/webbrowser.js
@@ -781,7 +781,12 @@ BrowserTabActor.prototype = {
reload = true;
}
- if (reload) {
+ // Reload if:
+ // - there's an explicit `performReload` flag and it's true
+ // - there's no `performReload` flag, but it makes sense to do so
+ let hasExplicitReloadFlag = "performReload" in options;
+ if ((hasExplicitReloadFlag && options.performReload) ||
+ (!hasExplicitReloadFlag && reload)) {
this.onReload();
}
},