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 @@ + + - + + + - + + +