diff options
Diffstat (limited to 'browser/devtools/netmonitor/netmonitor-view.js')
-rw-r--r-- | browser/devtools/netmonitor/netmonitor-view.js | 1825 |
1 files changed, 1825 insertions, 0 deletions
diff --git a/browser/devtools/netmonitor/netmonitor-view.js b/browser/devtools/netmonitor/netmonitor-view.js new file mode 100644 index 000000000..b8437dc6b --- /dev/null +++ b/browser/devtools/netmonitor/netmonitor-view.js @@ -0,0 +1,1825 @@ +/* -*- 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 HTML_NS = "http://www.w3.org/1999/xhtml"; +const EPSILON = 0.001; +const SOURCE_SYNTAX_HIGHLIGHT_MAX_FILE_SIZE = 102400; // 100 KB in bytes +const RESIZE_REFRESH_RATE = 50; // ms +const REQUESTS_REFRESH_RATE = 50; // ms +const REQUESTS_HEADERS_SAFE_BOUNDS = 30; // px +const REQUESTS_WATERFALL_SAFE_BOUNDS = 90; // px +const REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE = 5; // ms +const REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN = 60; // px +const REQUESTS_WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; // ms +const REQUESTS_WATERFALL_BACKGROUND_TICKS_SCALES = 3; +const REQUESTS_WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; // px +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 HEADERS_SIZE_DECIMALS = 3; +const CONTENT_SIZE_DECIMALS = 2; +const CONTENT_MIME_TYPE_ABBREVIATIONS = { + "ecmascript": "js", + "javascript": "js", + "x-javascript": "js" +}; +const CONTENT_MIME_TYPE_MAPPINGS = { + "/ecmascript": SourceEditor.MODES.JAVASCRIPT, + "/javascript": SourceEditor.MODES.JAVASCRIPT, + "/x-javascript": SourceEditor.MODES.JAVASCRIPT, + "/html": SourceEditor.MODES.HTML, + "/xhtml": SourceEditor.MODES.HTML, + "/xml": SourceEditor.MODES.HTML, + "/atom": SourceEditor.MODES.HTML, + "/soap": SourceEditor.MODES.HTML, + "/rdf": SourceEditor.MODES.HTML, + "/rss": SourceEditor.MODES.HTML, + "/css": SourceEditor.MODES.CSS +}; +const DEFAULT_EDITOR_CONFIG = { + mode: SourceEditor.MODES.TEXT, + readOnly: true, + showLineNumbers: true +}; +const GENERIC_VARIABLES_VIEW_SETTINGS = { + lazyEmpty: true, + lazyEmptyDelay: 10, // ms + searchEnabled: true, + editableValueTooltip: "", + editableNameTooltip: "", + preventDisableOnChage: true, + preventDescriptorModifiers: true, + eval: () => {}, + switch: () => {} +}; + +/** + * Object defining the network monitor view components. + */ +let NetMonitorView = { + /** + * Initializes the network monitor view. + * + * @param function aCallback + * Called after the view finishes initializing. + */ + initialize: function(aCallback) { + dumpn("Initializing the NetMonitorView"); + + this._initializePanes(); + + this.Toolbar.initialize(); + this.RequestsMenu.initialize(); + this.NetworkDetails.initialize(); + + aCallback(); + }, + + /** + * Destroys the network monitor view. + * + * @param function aCallback + * Called after the view finishes destroying. + */ + destroy: function(aCallback) { + dumpn("Destroying the NetMonitorView"); + + this.Toolbar.destroy(); + this.RequestsMenu.destroy(); + this.NetworkDetails.destroy(); + + this._destroyPanes(); + + aCallback(); + }, + + /** + * Initializes the UI for all the displayed panes. + */ + _initializePanes: function() { + dumpn("Initializing the NetMonitorView panes"); + + this._body = $("#body"); + this._detailsPane = $("#details-pane"); + this._detailsPaneToggleButton = $("#details-pane-toggle"); + + this._collapsePaneString = L10N.getStr("collapseDetailsPane"); + this._expandPaneString = L10N.getStr("expandDetailsPane"); + + this._detailsPane.setAttribute("width", Prefs.networkDetailsWidth); + this._detailsPane.setAttribute("height", Prefs.networkDetailsHeight); + this.toggleDetailsPane({ visible: false }); + }, + + /** + * Destroys the UI for all the displayed panes. + */ + _destroyPanes: function() { + dumpn("Destroying the NetMonitorView panes"); + + Prefs.networkDetailsWidth = this._detailsPane.getAttribute("width"); + Prefs.networkDetailsHeight = this._detailsPane.getAttribute("height"); + + this._detailsPane = null; + this._detailsPaneToggleButton = null; + }, + + /** + * Gets the visibility state of the network details pane. + * @return boolean + */ + get detailsPaneHidden() + this._detailsPane.hasAttribute("pane-collapsed"), + + /** + * Sets the network details pane hidden or visible. + * + * @param object aFlags + * An object containing some of the following properties: + * - visible: true if the pane should be shown, false to hide + * - animated: true to display an animation on toggle + * - delayed: true to wait a few cycles before toggle + * - callback: a function to invoke when the toggle finishes + * @param number aTabIndex [optional] + * The index of the intended selected tab in the details pane. + */ + toggleDetailsPane: function(aFlags, aTabIndex) { + let pane = this._detailsPane; + let button = this._detailsPaneToggleButton; + + ViewHelpers.togglePane(aFlags, pane); + + if (aFlags.visible) { + this._body.removeAttribute("pane-collapsed"); + button.removeAttribute("pane-collapsed"); + button.setAttribute("tooltiptext", this._collapsePaneString); + } else { + this._body.setAttribute("pane-collapsed", ""); + button.setAttribute("pane-collapsed", ""); + button.setAttribute("tooltiptext", this._expandPaneString); + } + + if (aTabIndex !== undefined) { + $("#details-pane").selectedIndex = aTabIndex; + } + }, + + /** + * Lazily initializes and returns a promise for a SourceEditor instance. + * + * @param string aId + * The id of the editor placeholder node. + * @return object + * A Promise that is resolved when the editor is available. + */ + editor: function(aId) { + dumpn("Getting a NetMonitorView editor: " + aId); + + if (this._editorPromises.has(aId)) { + return this._editorPromises.get(aId); + } + + let deferred = Promise.defer(); + this._editorPromises.set(aId, deferred.promise); + + // Initialize the source editor and store the newly created instance + // in the ether of a resolved promise's value. + new SourceEditor().init($(aId), DEFAULT_EDITOR_CONFIG, deferred.resolve); + + return deferred.promise; + }, + + _body: null, + _detailsPane: null, + _detailsPaneToggleButton: null, + _collapsePaneString: "", + _expandPaneString: "", + _editorPromises: new Map(), + _isInitialized: false, + _isDestroyed: false +}; + +/** + * Functions handling the toolbar view: expand/collapse button etc. + */ +function ToolbarView() { + dumpn("ToolbarView was instantiated"); + + this._onTogglePanesPressed = this._onTogglePanesPressed.bind(this); +} + +ToolbarView.prototype = { + /** + * Initialization function, called when the debugger is started. + */ + initialize: function() { + dumpn("Initializing the ToolbarView"); + + this._detailsPaneToggleButton = $("#details-pane-toggle"); + this._detailsPaneToggleButton.addEventListener("mousedown", this._onTogglePanesPressed, false); + }, + + /** + * Destruction function, called when the debugger is closed. + */ + destroy: function() { + dumpn("Destroying the ToolbarView"); + + this._detailsPaneToggleButton.removeEventListener("mousedown", this._onTogglePanesPressed, false); + }, + + /** + * Listener handling the toggle button click event. + */ + _onTogglePanesPressed: function() { + let requestsMenu = NetMonitorView.RequestsMenu; + let selectedIndex = requestsMenu.selectedIndex; + + // Make sure there's a selection if the button is pressed, to avoid + // showing an empty network details pane. + if (selectedIndex == -1 && requestsMenu.itemCount) { + requestsMenu.selectedIndex = 0; + } else { + requestsMenu.selectedIndex = -1; + } + }, + + _detailsPaneToggleButton: null +}; + +/** + * Functions handling the requests menu (containing details about each request, + * like status, method, file, domain, as well as a waterfall representing + * timing imformation). + */ +function RequestsMenuView() { + dumpn("RequestsMenuView was instantiated"); + + this._flushRequests = this._flushRequests.bind(this); + this._onSelect = this._onSelect.bind(this); + this._onResize = this._onResize.bind(this); + this._byFile = this._byFile.bind(this); + this._byDomain = this._byDomain.bind(this); + this._byType = this._byType.bind(this); +} + +RequestsMenuView.prototype = Heritage.extend(WidgetMethods, { + /** + * Initialization function, called when the network monitor is started. + */ + initialize: function() { + dumpn("Initializing the RequestsMenuView"); + + this.widget = new SideMenuWidget($("#requests-menu-contents"), false); + this._summary = $("#request-menu-network-summary"); + + this.widget.maintainSelectionVisible = false; + this.widget.autoscrollWithAppendedItems = true; + + this.widget.addEventListener("select", this._onSelect, false); + window.addEventListener("resize", this._onResize, false); + }, + + /** + * Destruction function, called when the network monitor is closed. + */ + destroy: function() { + dumpn("Destroying the SourcesView"); + + this.widget.removeEventListener("select", this._onSelect, false); + window.removeEventListener("resize", this._onResize, false); + }, + + /** + * Resets this container (removes all the networking information). + */ + reset: function() { + this.empty(); + this._firstRequestStartedMillis = -1; + this._lastRequestEndedMillis = -1; + }, + + /** + * Specifies if this view may be updated lazily. + */ + lazyUpdate: true, + + /** + * Adds a network request to this container. + * + * @param string aId + * An identifier coming from the network monitor controller. + * @param string aStartedDateTime + * A string representation of when the request was started, which + * can be parsed by Date (for example "2012-09-17T19:50:03.699Z"). + * @param string aMethod + * Specifies the request method (e.g. "GET", "POST", etc.) + * @param string aUrl + * Specifies the request's url. + * @param boolean aIsXHR + * True if this request was initiated via XHR. + */ + addRequest: function(aId, aStartedDateTime, aMethod, aUrl, aIsXHR) { + // Convert the received date/time string to a unix timestamp. + let unixTime = Date.parse(aStartedDateTime); + + // Create the element node for the network request item. + let menuView = this._createMenuView(aMethod, aUrl); + + // Remember the first and last event boundaries. + this._registerFirstRequestStart(unixTime); + this._registerLastRequestEnd(unixTime); + + // Append a network request item to this container. + let requestItem = this.push([menuView, aId], { + attachment: { + startedDeltaMillis: unixTime - this._firstRequestStartedMillis, + startedMillis: unixTime, + method: aMethod, + url: aUrl, + isXHR: aIsXHR + } + }); + + $("#details-pane-toggle").disabled = false; + $("#requests-menu-empty-notice").hidden = true; + + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Filters all network requests in this container by a specified type. + * + * @param string aType + * Either "all", "html", "css", "js", "xhr", "fonts", "images", "media" + * or "flash". + */ + filterOn: function(aType = "all") { + let target = $("#requests-menu-filter-" + aType + "-button"); + let buttons = document.querySelectorAll(".requests-menu-footer-button"); + + for (let button of buttons) { + if (button != target) { + button.removeAttribute("checked"); + } else { + button.setAttribute("checked", "true"); + } + } + + // Filter on whatever was requested. + switch (aType) { + case "all": + this.filterContents(() => true); + break; + case "html": + this.filterContents(this._onHtml); + break; + case "css": + this.filterContents(this._onCss); + break; + case "js": + this.filterContents(this._onJs); + break; + case "xhr": + this.filterContents(this._onXhr); + break; + case "fonts": + this.filterContents(this._onFonts); + break; + case "images": + this.filterContents(this._onImages); + break; + case "media": + this.filterContents(this._onMedia); + break; + case "flash": + this.filterContents(this._onFlash); + break; + } + + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Sorts all network requests in this container by a specified detail. + * + * @param string aType + * Either "status", "method", "file", "domain", "type", "size" or + * "waterfall". + */ + sortBy: function(aType = "waterfall") { + let target = $("#requests-menu-" + aType + "-button"); + let headers = document.querySelectorAll(".requests-menu-header-button"); + + for (let header of headers) { + if (header != target) { + header.removeAttribute("sorted"); + header.removeAttribute("tooltiptext"); + } + } + + let direction = ""; + if (target) { + if (target.getAttribute("sorted") == "ascending") { + target.setAttribute("sorted", direction = "descending"); + target.setAttribute("tooltiptext", L10N.getStr("networkMenu.sortedDesc")); + } else { + target.setAttribute("sorted", direction = "ascending"); + target.setAttribute("tooltiptext", L10N.getStr("networkMenu.sortedAsc")); + } + } + + // Sort by whatever was requested. + switch (aType) { + case "status": + if (direction == "ascending") { + this.sortContents(this._byStatus); + } else { + this.sortContents((a, b) => !this._byStatus(a, b)); + } + break; + case "method": + if (direction == "ascending") { + this.sortContents(this._byMethod); + } else { + this.sortContents((a, b) => !this._byMethod(a, b)); + } + break; + case "file": + if (direction == "ascending") { + this.sortContents(this._byFile); + } else { + this.sortContents((a, b) => !this._byFile(a, b)); + } + break; + case "domain": + if (direction == "ascending") { + this.sortContents(this._byDomain); + } else { + this.sortContents((a, b) => !this._byDomain(a, b)); + } + break; + case "type": + if (direction == "ascending") { + this.sortContents(this._byType); + } else { + this.sortContents((a, b) => !this._byType(a, b)); + } + break; + case "size": + if (direction == "ascending") { + this.sortContents(this._bySize); + } else { + this.sortContents((a, b) => !this._bySize(a, b)); + } + break; + case "waterfall": + if (direction == "ascending") { + this.sortContents(this._byTiming); + } else { + this.sortContents((a, b) => !this._byTiming(a, b)); + } + break; + } + + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Predicates used when filtering items. + * + * @param object aItem + * The filtered item. + * @return boolean + * True if the item should be visible, false otherwise. + */ + _onHtml: function({ attachment: { mimeType } }) + mimeType && mimeType.contains("/html"), + + _onCss: function({ attachment: { mimeType } }) + mimeType && mimeType.contains("/css"), + + _onJs: function({ attachment: { mimeType } }) + mimeType && ( + mimeType.contains("/ecmascript") || + mimeType.contains("/javascript") || + mimeType.contains("/x-javascript")), + + _onXhr: function({ attachment: { isXHR } }) + isXHR, + + _onFonts: function({ attachment: { url, mimeType } }) // Fonts are a mess. + (mimeType && ( + mimeType.contains("font/") || + mimeType.contains("/font"))) || + url.contains(".eot") || + url.contains(".ttf") || + url.contains(".otf") || + url.contains(".woff"), + + _onImages: function({ attachment: { mimeType } }) + mimeType && mimeType.contains("image/"), + + _onMedia: 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. + (mimeType && ( + mimeType.contains("/x-flv") || + mimeType.contains("/x-shockwave-flash"))) || + url.contains(".swf") || + url.contains(".flv"), + + /** + * Predicates used when sorting items. + * + * @param object aFirst + * The first item used in the comparison. + * @param object aSecond + * The second item used in the comparison. + * @return number + * -1 to sort aFirst to a lower index than aSecond + * 0 to leave aFirst and aSecond unchanged with respect to each other + * 1 to sort aSecond to a lower index than aFirst + */ + _byTiming: function({ attachment: first }, { attachment: second }) + first.startedMillis > second.startedMillis, + + _byStatus: function({ attachment: first }, { attachment: second }) + first.status > second.status, + + _byMethod: function({ attachment: first }, { attachment: second }) + first.method > second.method, + + _byFile: function({ attachment: first }, { attachment: second }) + this._getUriNameWithQuery(first.url).toLowerCase() > + this._getUriNameWithQuery(second.url).toLowerCase(), + + _byDomain: function({ attachment: first }, { attachment: second }) + this._getUriHostPort(first.url).toLowerCase() > + this._getUriHostPort(second.url).toLowerCase(), + + _byType: function({ attachment: first }, { attachment: second }) + this._getAbbreviatedMimeType(first.mimeType).toLowerCase() > + this._getAbbreviatedMimeType(second.mimeType).toLowerCase(), + + _bySize: function({ attachment: first }, { attachment: second }) + first.contentSize > second.contentSize, + + /** + * Refreshes the status displayed in this container's footer, providing + * concise information about all requests. + */ + refreshSummary: function() { + let visibleItems = this.visibleItems; + let visibleRequestsCount = visibleItems.length; + if (!visibleRequestsCount) { + this._summary.setAttribute("value", L10N.getStr("networkMenu.empty")); + return; + } + + let totalBytes = this._getTotalBytesOfRequests(visibleItems); + let totalMillis = + this._getNewestRequest(visibleItems).attachment.endedMillis - + this._getOldestRequest(visibleItems).attachment.startedMillis; + + // https://developer.mozilla.org/en-US/docs/Localization_and_Plurals + 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)) + ); + }, + + /** + * Adds odd/even attributes to all the visible items in this container. + */ + refreshZebra: function() { + let visibleItems = this.orderedVisibleItems; + + for (let i = 0, len = visibleItems.length; i < len; i++) { + let requestItem = visibleItems[i]; + let requestTarget = requestItem.target; + + if (i % 2 == 0) { + requestTarget.setAttribute("even", ""); + requestTarget.removeAttribute("odd"); + } else { + requestTarget.setAttribute("odd", ""); + requestTarget.removeAttribute("even"); + } + } + }, + + /** + * Schedules adding additional information to a network request. + * + * @param string aId + * An identifier coming from the network monitor controller. + * @param object aData + * An object containing several { key: value } tuples of network info. + * Supported keys are "httpVersion", "status", "statusText" etc. + */ + updateRequest: function(aId, aData) { + // Prevent interference from zombie updates received after target closed. + if (NetMonitorView._isDestroyed) { + return; + } + this._updateQueue.push([aId, aData]); + + // Lazy updating is disabled in some tests. + if (!this.lazyUpdate) { + return void this._flushRequests(); + } + // Allow requests to settle down first. + drain("update-requests", REQUESTS_REFRESH_RATE, () => this._flushRequests()); + }, + + /** + * Starts adding all queued additional information about network requests. + */ + _flushRequests: function() { + // For each queued additional information packet, get the corresponding + // request item in the view and update it based on the specified data. + for (let [id, data] of this._updateQueue) { + let requestItem = this.getItemByValue(id); + if (!requestItem) { + // Packet corresponds to a dead request item, target navigated. + continue; + } + + // Each information packet may contain several { key: value } tuples of + // network info, so update the view based on each one. + for (let key in data) { + let value = data[key]; + if (value === undefined) { + // The information in the packet is empty, it can be safely ignored. + continue; + } + + switch (key) { + case "requestHeaders": + requestItem.attachment.requestHeaders = value; + break; + case "requestCookies": + requestItem.attachment.requestCookies = value; + break; + case "requestPostData": + requestItem.attachment.requestPostData = value; + break; + case "responseHeaders": + requestItem.attachment.responseHeaders = value; + break; + case "responseCookies": + requestItem.attachment.responseCookies = value; + break; + case "httpVersion": + requestItem.attachment.httpVersion = value; + break; + case "status": + requestItem.attachment.status = value; + this._updateMenuView(requestItem, key, value); + break; + case "statusText": + requestItem.attachment.statusText = value; + this._updateMenuView(requestItem, key, + requestItem.attachment.status + " " + + requestItem.attachment.statusText); + break; + case "headersSize": + requestItem.attachment.headersSize = value; + break; + case "contentSize": + requestItem.attachment.contentSize = value; + this._updateMenuView(requestItem, key, value); + break; + case "mimeType": + requestItem.attachment.mimeType = value; + this._updateMenuView(requestItem, key, value); + break; + case "responseContent": + requestItem.attachment.responseContent = value; + break; + case "totalTime": + requestItem.attachment.totalTime = value; + requestItem.attachment.endedMillis = requestItem.attachment.startedMillis + value; + this._updateMenuView(requestItem, key, value); + this._registerLastRequestEnd(requestItem.attachment.endedMillis); + break; + case "eventTimings": + requestItem.attachment.eventTimings = value; + this._createWaterfallView(requestItem, value.timings); + break; + } + } + // This update may have additional information about a request which + // isn't shown yet in the network details pane. + let selectedItem = this.selectedItem; + if (selectedItem && selectedItem.value == id) { + NetMonitorView.NetworkDetails.populate(selectedItem.attachment); + } + } + + // We're done flushing all the requests, clear the update queue. + this._updateQueue = []; + + // Make sure all the requests are sorted and filtered. + // Freshly added requests may not yet contain all the information required + // for sorting and filtering predicates, so this is done each time the + // network requests table is flushed (don't worry, events are drained first + // so this doesn't happen once per network event update). + this.sortContents(); + this.filterContents(); + this.refreshSummary(); + this.refreshZebra(); + }, + + /** + * Customization function for creating an item's UI. + * + * @param string aMethod + * Specifies the request method (e.g. "GET", "POST", etc.) + * @param string aUrl + * Specifies the request's url. + * @return nsIDOMNode + * The network request view. + */ + _createMenuView: function(aMethod, aUrl) { + let uri = nsIURL(aUrl); + let nameWithQuery = this._getUriNameWithQuery(uri); + let hostPort = this._getUriHostPort(uri); + + let template = $("#requests-menu-item-template"); + let fragment = document.createDocumentFragment(); + + let method = $(".requests-menu-method", template); + method.setAttribute("value", aMethod); + + let file = $(".requests-menu-file", template); + file.setAttribute("value", nameWithQuery); + file.setAttribute("tooltiptext", nameWithQuery); + + let domain = $(".requests-menu-domain", template); + domain.setAttribute("value", hostPort); + domain.setAttribute("tooltiptext", hostPort); + + let waterfall = $(".requests-menu-waterfall", template); + waterfall.style.backgroundImage = this._cachedWaterfallBackground; + + // Flatten the DOM by removing one redundant box (the template container). + for (let node of template.childNodes) { + fragment.appendChild(node.cloneNode(true)); + } + + return fragment; + }, + + /** + * Updates the information displayed in a network request item view. + * + * @param object aItem + * The network request item in this container. + * @param string aKey + * The type of information that is to be updated. + * @param any aValue + * The new value to be shown. + */ + _updateMenuView: function(aItem, aKey, aValue) { + switch (aKey) { + case "status": { + let node = $(".requests-menu-status", aItem.target); + node.setAttribute("code", aValue); + break; + } + case "statusText": { + let node = $(".requests-menu-status-and-method", aItem.target); + node.setAttribute("tooltiptext", aValue); + break; + } + case "contentSize": { + let kb = aValue / 1024; + let size = L10N.numberWithDecimals(kb, CONTENT_SIZE_DECIMALS); + let node = $(".requests-menu-size", aItem.target); + let text = L10N.getFormatStr("networkMenu.sizeKB", size); + node.setAttribute("value", text); + node.setAttribute("tooltiptext", text); + break; + } + case "mimeType": { + let type = this._getAbbreviatedMimeType(aValue); + let node = $(".requests-menu-type", aItem.target); + let text = CONTENT_MIME_TYPE_ABBREVIATIONS[type] || type; + node.setAttribute("value", text); + node.setAttribute("tooltiptext", aValue); + break; + } + case "totalTime": { + let node = $(".requests-menu-timings-total", aItem.target); + let text = L10N.getFormatStr("networkMenu.totalMS", aValue); // integer + node.setAttribute("value", text); + node.setAttribute("tooltiptext", text); + break; + } + } + }, + + /** + * Creates a waterfall representing timing information in a network request item view. + * + * @param object aItem + * The network request item in this container. + * @param object aTimings + * An object containing timing information. + */ + _createWaterfallView: function(aItem, aTimings) { + let { target, attachment } = aItem; + let sections = ["dns", "connect", "send", "wait", "receive"]; + // Skipping "blocked" because it doesn't work yet. + + let timingsNode = $(".requests-menu-timings", target); + let startCapNode = $(".requests-menu-timings-cap.start", timingsNode); + let endCapNode = $(".requests-menu-timings-cap.end", timingsNode); + let firstBox; + + // Add a set of boxes representing timing information. + for (let key of sections) { + let width = aTimings[key]; + + // Don't render anything if it surely won't be visible. + // One millisecond == one unscaled pixel. + if (width > 0) { + let timingBox = document.createElement("hbox"); + timingBox.className = "requests-menu-timings-box " + key; + timingBox.setAttribute("width", width); + timingsNode.insertBefore(timingBox, endCapNode); + + // Make the start cap inherit the aspect of the first timing box. + if (!firstBox) { + firstBox = timingBox; + startCapNode.classList.add(key); + } + // Same goes for the end cap, inherit the aspect of the last timing box. + endCapNode.classList.add(key); + } + } + + // Since at least one timing box should've been rendered, unhide the + // start and end timing cap nodes. + startCapNode.hidden = false; + endCapNode.hidden = false; + + // Rescale all the waterfalls so that everything is visible at once. + this._flushWaterfallViews(); + }, + + /** + * Rescales and redraws all the waterfall views in this container. + * + * @param boolean aReset + * True if this container's width was changed. + */ + _flushWaterfallViews: function(aReset) { + // To avoid expensive operations like getBoundingClientRect() and + // rebuilding the waterfall background each time a new request comes in, + // stuff is cached. However, in certain scenarios like when the window + // is resized, this needs to be invalidated. + if (aReset) { + this._cachedWaterfallWidth = 0; + this._hideOverflowingColumns(); + } + + // Determine the scaling to be applied to all the waterfalls so that + // everything is visible at once. One millisecond == one unscaled pixel. + let availableWidth = this._waterfallWidth - REQUESTS_WATERFALL_SAFE_BOUNDS; + let longestWidth = this._lastRequestEndedMillis - this._firstRequestStartedMillis; + let scale = Math.min(Math.max(availableWidth / longestWidth, EPSILON), 1); + + // Redraw and set the canvas background for each waterfall view. + this._showWaterfallDivisionLabels(scale); + this._drawWaterfallBackground(scale); + this._flushWaterfallBackgrounds(); + + // Apply CSS transforms to each waterfall in this container totalTime + // accurately translate and resize as needed. + for (let { target, attachment } in this) { + let timingsNode = $(".requests-menu-timings", target); + let startCapNode = $(".requests-menu-timings-cap.start", target); + let endCapNode = $(".requests-menu-timings-cap.end", target); + let totalNode = $(".requests-menu-timings-total", target); + let direction = window.isRTL ? -1 : 1; + + // Render the timing information at a specific horizontal translation + // based on the delta to the first monitored event network. + let translateX = "translateX(" + (direction * attachment.startedDeltaMillis) + "px)"; + + // Based on the total time passed until the last request, rescale + // all the waterfalls to a reasonable size. + let scaleX = "scaleX(" + scale + ")"; + + // Certain nodes should not be scaled, even if they're children of + // another scaled node. In this case, apply a reversed transformation. + let revScaleX = "scaleX(" + (1 / scale) + ")"; + + timingsNode.style.transform = scaleX + " " + translateX; + startCapNode.style.transform = revScaleX + " translateX(" + (direction * 0.5) + "px)"; + endCapNode.style.transform = revScaleX + " translateX(" + (direction * -0.5) + "px)"; + totalNode.style.transform = revScaleX; + } + }, + + /** + * Creates the labels displayed on the waterfall header in this container. + * + * @param number aScale + * The current waterfall scale. + */ + _showWaterfallDivisionLabels: function(aScale) { + let container = $("#requests-menu-waterfall-button"); + let availableWidth = this._waterfallWidth - REQUESTS_WATERFALL_SAFE_BOUNDS; + + // Nuke all existing labels. + while (container.hasChildNodes()) { + container.firstChild.remove(); + } + + // Build new millisecond tick labels... + let timingStep = REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE; + let optimalTickIntervalFound = false; + + while (!optimalTickIntervalFound) { + // Ignore any divisions that would end up being too close to each other. + let scaledStep = aScale * timingStep; + if (scaledStep < REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN) { + timingStep <<= 1; + continue; + } + optimalTickIntervalFound = true; + + // Insert one label for each division on the current scale. + let fragment = document.createDocumentFragment(); + let direction = window.isRTL ? -1 : 1; + + for (let x = 0; x < availableWidth; x += scaledStep) { + let divisionMS = (x / aScale).toFixed(0); + let translateX = "translateX(" + ((direction * x) | 0) + "px)"; + + let node = document.createElement("label"); + let text = L10N.getFormatStr("networkMenu.divisionMS", divisionMS); + node.className = "plain requests-menu-timings-division"; + node.style.transform = translateX; + + node.setAttribute("value", text); + fragment.appendChild(node); + } + container.appendChild(fragment); + } + }, + + /** + * Creates the background displayed on each waterfall view in this container. + * + * @param number aScale + * The current waterfall scale. + */ + _drawWaterfallBackground: function(aScale) { + if (!this._canvas || !this._ctx) { + this._canvas = document.createElementNS(HTML_NS, "canvas"); + this._ctx = this._canvas.getContext("2d"); + } + let canvas = this._canvas; + let ctx = this._ctx; + + // Nuke the context. + let canvasWidth = canvas.width = this._waterfallWidth; + let canvasHeight = canvas.height = 1; // Awww yeah, 1px, repeats on Y axis. + + // Start over. + let imageData = ctx.createImageData(canvasWidth, canvasHeight); + let pixelArray = imageData.data; + + let buf = new ArrayBuffer(pixelArray.length); + let buf8 = new Uint8ClampedArray(buf); + let data32 = new Uint32Array(buf); + + // Build new millisecond tick lines... + let timingStep = REQUESTS_WATERFALL_BACKGROUND_TICKS_MULTIPLE; + let [r, g, b] = REQUESTS_WATERFALL_BACKGROUND_TICKS_COLOR_RGB; + let alphaComponent = REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_MIN; + let optimalTickIntervalFound = false; + + while (!optimalTickIntervalFound) { + // Ignore any divisions that would end up being too close to each other. + let scaledStep = aScale * timingStep; + if (scaledStep < REQUESTS_WATERFALL_BACKGROUND_TICKS_SPACING_MIN) { + timingStep <<= 1; + continue; + } + optimalTickIntervalFound = true; + + // Insert one pixel for each division on each scale. + for (let i = 1; i <= REQUESTS_WATERFALL_BACKGROUND_TICKS_SCALES; i++) { + let increment = scaledStep * Math.pow(2, i); + for (let x = 0; x < canvasWidth; x += increment) { + let position = (window.isRTL ? canvasWidth - x : x) | 0; + data32[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r; + } + alphaComponent += REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_ADD; + } + } + + // Flush the image data and cache the waterfall background. + pixelArray.set(buf8); + ctx.putImageData(imageData, 0, 0); + this._cachedWaterfallBackground = "url(" + canvas.toDataURL() + ")"; + }, + + /** + * Reapplies the current waterfall background on all request items. + */ + _flushWaterfallBackgrounds: function() { + for (let { target } in this) { + let waterfallNode = $(".requests-menu-waterfall", target); + waterfallNode.style.backgroundImage = this._cachedWaterfallBackground; + } + }, + + /** + * Hides the overflowing columns in the requests table. + */ + _hideOverflowingColumns: function() { + if (window.isRTL) { + return; + } + let table = $("#network-table"); + let toolbar = $("#requests-menu-toolbar"); + let columns = [ + ["#requests-menu-waterfall-header-box", "waterfall-overflows"], + ["#requests-menu-size-header-box", "size-overflows"], + ["#requests-menu-type-header-box", "type-overflows"], + ["#requests-menu-domain-header-box", "domain-overflows"] + ]; + + // Flush headers. + columns.forEach(([, attribute]) => table.removeAttribute(attribute)); + let availableWidth = toolbar.getBoundingClientRect().width; + + // Hide the columns. + columns.forEach(([id, attribute]) => { + let bounds = $(id).getBoundingClientRect(); + if (bounds.right > availableWidth - REQUESTS_HEADERS_SAFE_BOUNDS) { + table.setAttribute(attribute, ""); + } + }); + }, + + /** + * The selection listener for this container. + */ + _onSelect: function({ detail: item }) { + if (item) { + NetMonitorView.NetworkDetails.populate(item.attachment); + NetMonitorView.NetworkDetails.toggle(true); + } else { + NetMonitorView.NetworkDetails.toggle(false); + } + }, + + /** + * The resize listener for this container's window. + */ + _onResize: function(e) { + // Allow requests to settle down first. + drain("resize-events", RESIZE_REFRESH_RATE, () => this._flushWaterfallViews(true)); + }, + + /** + * Checks if the specified unix time is the first one to be known of, + * and saves it if so. + * + * @param number aUnixTime + * The milliseconds to check and save. + */ + _registerFirstRequestStart: function(aUnixTime) { + if (this._firstRequestStartedMillis == -1) { + this._firstRequestStartedMillis = aUnixTime; + } + }, + + /** + * Checks if the specified unix time is the last one to be known of, + * and saves it if so. + * + * @param number aUnixTime + * The milliseconds to check and save. + */ + _registerLastRequestEnd: function(aUnixTime) { + if (this._lastRequestEndedMillis < aUnixTime) { + this._lastRequestEndedMillis = aUnixTime; + } + }, + + /** + * Helpers for getting details about an nsIURL. + * + * @param nsIURL | string aUrl + * @return string + */ + _getUriNameWithQuery: function(aUrl) { + if (!(aUrl instanceof Ci.nsIURL)) { + aUrl = nsIURL(aUrl); + } + let name = NetworkHelper.convertToUnicode(unescape(aUrl.fileName)) || "/"; + let query = NetworkHelper.convertToUnicode(unescape(aUrl.query)); + return name + (query ? "?" + query : ""); + }, + _getUriHostPort: function(aUrl) { + if (!(aUrl instanceof Ci.nsIURL)) { + aUrl = nsIURL(aUrl); + } + return NetworkHelper.convertToUnicode(unescape(aUrl.hostPort)); + }, + + /** + * Helper for getting an abbreviated string for a mime type. + * + * @param string aMimeType + * @return string + */ + _getAbbreviatedMimeType: function(aMimeType) { + if (!aMimeType) { + return ""; + } + return (aMimeType.split(";")[0].split("/")[1] || "").split("+")[0]; + }, + + /** + * Gets the total number of bytes representing the cumulated content size of + * a set of requests. Returns 0 for an empty set. + * + * @param array aItemsArray + * @return number + */ + _getTotalBytesOfRequests: function(aItemsArray) { + if (!aItemsArray.length) { + return 0; + } + return aItemsArray.reduce((prev, curr) => prev + curr.attachment.contentSize || 0, 0); + }, + + /** + * Gets the oldest (first performed) request in a set. Returns null for an + * empty set. + * + * @param array aItemsArray + * @return object + */ + _getOldestRequest: function(aItemsArray) { + if (!aItemsArray.length) { + return null; + } + return aItemsArray.reduce((prev, curr) => + prev.attachment.startedMillis < curr.attachment.startedMillis ? prev : curr); + }, + + /** + * Gets the newest (latest performed) request in a set. Returns null for an + * empty set. + * + * @param array aItemsArray + * @return object + */ + _getNewestRequest: function(aItemsArray) { + if (!aItemsArray.length) { + return null; + } + return aItemsArray.reduce((prev, curr) => + prev.attachment.startedMillis > curr.attachment.startedMillis ? prev : curr); + }, + + /** + * Gets the available waterfall width in this container. + * @return number + */ + get _waterfallWidth() { + if (this._cachedWaterfallWidth == 0) { + let container = $("#requests-menu-toolbar"); + let waterfall = $("#requests-menu-waterfall-header-box"); + let containerBounds = container.getBoundingClientRect(); + let waterfallBounds = waterfall.getBoundingClientRect(); + if (!window.isRTL) { + this._cachedWaterfallWidth = containerBounds.width - waterfallBounds.left; + } else { + this._cachedWaterfallWidth = waterfallBounds.right; + } + } + return this._cachedWaterfallWidth; + }, + + _summary: null, + _canvas: null, + _ctx: null, + _cachedWaterfallWidth: 0, + _cachedWaterfallBackground: "", + _firstRequestStartedMillis: -1, + _lastRequestEndedMillis: -1, + _updateQueue: [], + _updateTimeout: null, + _resizeTimeout: null +}); + +/** + * Functions handling the requests details view. + */ +function NetworkDetailsView() { + dumpn("NetworkDetailsView was instantiated"); + + this._onTabSelect = this._onTabSelect.bind(this); +}; + +NetworkDetailsView.prototype = { + /** + * Initialization function, called when the network monitor is started. + */ + initialize: function() { + dumpn("Initializing the RequestsMenuView"); + + this.widget = $("#details-pane"); + + this._headers = new VariablesView($("#all-headers"), + Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, { + emptyText: L10N.getStr("headersEmptyText"), + searchPlaceholder: L10N.getStr("headersFilterText") + })); + this._cookies = new VariablesView($("#all-cookies"), + Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, { + emptyText: L10N.getStr("cookiesEmptyText"), + searchPlaceholder: L10N.getStr("cookiesFilterText") + })); + this._params = new VariablesView($("#request-params"), + Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, { + emptyText: L10N.getStr("paramsEmptyText"), + searchPlaceholder: L10N.getStr("paramsFilterText") + })); + this._json = new VariablesView($("#response-content-json"), + Heritage.extend(GENERIC_VARIABLES_VIEW_SETTINGS, { + searchPlaceholder: L10N.getStr("jsonFilterText") + })); + + this._paramsQueryString = L10N.getStr("paramsQueryString"); + this._paramsFormData = L10N.getStr("paramsFormData"); + this._paramsPostPayload = L10N.getStr("paramsPostPayload"); + this._requestHeaders = L10N.getStr("requestHeaders"); + this._responseHeaders = L10N.getStr("responseHeaders"); + this._requestCookies = L10N.getStr("requestCookies"); + this._responseCookies = L10N.getStr("responseCookies"); + + $("tabpanels", this.widget).addEventListener("select", this._onTabSelect); + }, + + /** + * Destruction function, called when the network monitor is closed. + */ + destroy: function() { + dumpn("Destroying the SourcesView"); + }, + + /** + * Sets this view hidden or visible. It's visible by default. + * + * @param boolean aVisibleFlag + * Specifies the intended visibility. + */ + toggle: function(aVisibleFlag) { + NetMonitorView.toggleDetailsPane({ visible: aVisibleFlag }); + NetMonitorView.RequestsMenu._flushWaterfallViews(true); + }, + + /** + * Hides and resets this container (removes all the networking information). + */ + reset: function() { + this.toggle(false); + this._dataSrc = null; + }, + + /** + * Populates this view with the specified data. + * + * @param object aData + * The data source (this should be the attachment of a request item). + */ + populate: function(aData) { + $("#request-params-box").setAttribute("flex", "1"); + $("#request-params-box").hidden = false; + $("#request-post-data-textarea-box").hidden = true; + $("#response-content-info-header").hidden = true; + $("#response-content-json-box").hidden = true; + $("#response-content-textarea-box").hidden = true; + $("#response-content-image-box").hidden = true; + + this._headers.empty(); + this._cookies.empty(); + this._params.empty(); + this._json.empty(); + + this._dataSrc = { src: aData, populated: [] }; + this._onTabSelect(); + }, + + /** + * Listener handling the tab selection event. + */ + _onTabSelect: function() { + let { src, populated } = this._dataSrc || {}; + let tab = this.widget.selectedIndex; + + // Make sure the data source is valid and don't populate the same tab twice. + if (!src || populated[tab]) { + return; + } + + switch (tab) { + case 0: // "Headers" + this._setSummary(src); + this._setResponseHeaders(src.responseHeaders); + this._setRequestHeaders(src.requestHeaders); + break; + case 1: // "Cookies" + this._setResponseCookies(src.responseCookies); + this._setRequestCookies(src.requestCookies); + break; + case 2: // "Params" + this._setRequestGetParams(src.url); + this._setRequestPostParams(src.requestHeaders, src.requestPostData); + break; + case 3: // "Response" + this._setResponseBody(src.url, src.responseContent); + break; + case 4: // "Timings" + this._setTimingsInformation(src.eventTimings); + break; + } + + populated[tab] = true; + }, + + /** + * Sets the network request summary shown in this view. + * + * @param object aData + * The data source (this should be the attachment of a request item). + */ + _setSummary: function(aData) { + if (aData.url) { + let unicodeUrl = NetworkHelper.convertToUnicode(unescape(aData.url)); + $("#headers-summary-url-value").setAttribute("value", unicodeUrl); + $("#headers-summary-url-value").setAttribute("tooltiptext", unicodeUrl); + $("#headers-summary-url").removeAttribute("hidden"); + } else { + $("#headers-summary-url").setAttribute("hidden", "true"); + } + + if (aData.method) { + $("#headers-summary-method-value").setAttribute("value", aData.method); + $("#headers-summary-method").removeAttribute("hidden"); + } else { + $("#headers-summary-method").setAttribute("hidden", "true"); + } + + if (aData.status) { + $("#headers-summary-status-circle").setAttribute("code", aData.status); + $("#headers-summary-status-value").setAttribute("value", aData.status + " " + aData.statusText); + $("#headers-summary-status").removeAttribute("hidden"); + } else { + $("#headers-summary-status").setAttribute("hidden", "true"); + } + + if (aData.httpVersion && aData.httpVersion != DEFAULT_HTTP_VERSION) { + $("#headers-summary-version-value").setAttribute("value", aData.httpVersion); + $("#headers-summary-version").removeAttribute("hidden"); + } else { + $("#headers-summary-version").setAttribute("hidden", "true"); + } + }, + + /** + * Sets the network request headers shown in this view. + * + * @param object aResponse + * The message received from the server. + */ + _setRequestHeaders: function(aResponse) { + if (aResponse && aResponse.headers.length) { + this._addHeaders(this._requestHeaders, aResponse); + } + }, + + /** + * Sets the network response headers shown in this view. + * + * @param object aResponse + * The message received from the server. + */ + _setResponseHeaders: function(aResponse) { + if (aResponse && aResponse.headers.length) { + aResponse.headers.sort((a, b) => a.name > b.name); + this._addHeaders(this._responseHeaders, aResponse); + } + }, + + /** + * Populates the headers container in this view with the specified data. + * + * @param string aName + * The type of headers to populate (request or response). + * @param object aResponse + * The message received from the server. + */ + _addHeaders: function(aName, aResponse) { + let kb = aResponse.headersSize / 1024; + let size = L10N.numberWithDecimals(kb, HEADERS_SIZE_DECIMALS); + let text = L10N.getFormatStr("networkMenu.sizeKB", size); + let headersScope = this._headers.addScope(aName + " (" + text + ")"); + headersScope.expanded = true; + + for (let header of aResponse.headers) { + let headerVar = headersScope.addItem(header.name, {}, true); + gNetwork.getString(header.value).then((aString) => headerVar.setGrip(aString)); + } + }, + + /** + * Sets the network request cookies shown in this view. + * + * @param object aResponse + * The message received from the server. + */ + _setRequestCookies: function(aResponse) { + if (aResponse && aResponse.cookies.length) { + aResponse.cookies.sort((a, b) => a.name > b.name); + this._addCookies(this._requestCookies, aResponse); + } + }, + + /** + * Sets the network response cookies shown in this view. + * + * @param object aResponse + * The message received from the server. + */ + _setResponseCookies: function(aResponse) { + if (aResponse && aResponse.cookies.length) { + this._addCookies(this._responseCookies, aResponse); + } + }, + + /** + * Populates the cookies container in this view with the specified data. + * + * @param string aName + * The type of cookies to populate (request or response). + * @param object aResponse + * The message received from the server. + */ + _addCookies: function(aName, aResponse) { + let cookiesScope = this._cookies.addScope(aName); + cookiesScope.expanded = true; + + for (let cookie of aResponse.cookies) { + let cookieVar = cookiesScope.addItem(cookie.name, {}, true); + gNetwork.getString(cookie.value).then((aString) => cookieVar.setGrip(aString)); + + // By default the cookie name and value are shown. If this is the only + // information available, then nothing else is to be displayed. + let cookieProps = Object.keys(cookie); + if (cookieProps.length == 2) { + continue; + } + + // Display any other information other than the cookie name and value + // which may be available. + let rawObject = Object.create(null); + let otherProps = cookieProps.filter((e) => e != "name" && e != "value"); + for (let prop of otherProps) { + rawObject[prop] = cookie[prop]; + } + cookieVar.populate(rawObject); + cookieVar.twisty = true; + cookieVar.expanded = true; + } + }, + + /** + * Sets the network request get params shown in this view. + * + * @param string aUrl + * The request's url. + */ + _setRequestGetParams: function(aUrl) { + let query = nsIURL(aUrl).query; + if (query) { + this._addParams(this._paramsQueryString, query); + } + }, + + /** + * Sets the network request post params shown in this view. + * + * @param object aHeadersResponse + * The "requestHeaders" message received from the server. + * @param object aPostDataResponse + * The "requestPostData" message received from the server. + */ + _setRequestPostParams: function(aHeadersResponse, aPostDataResponse) { + if (!aHeadersResponse || !aPostDataResponse) { + return; + } + gNetwork.getString(aPostDataResponse.postData.text).then((aString) => { + // Handle query strings (poor man's forms, e.g. "?foo=bar&baz=42"). + let cType = aHeadersResponse.headers.filter(({ name }) => name == "Content-Type")[0]; + let cString = cType ? cType.value : ""; + if (cString.contains("x-www-form-urlencoded") || + aString.contains("x-www-form-urlencoded")) { + let formDataGroups = aString.split(/\r\n|\n|\r/); + for (let group of formDataGroups) { + this._addParams(this._paramsFormData, group); + } + } + // Handle actual forms ("multipart/form-data" content type). + else { + // This is really awkward, but hey, it works. Let's show an empty + // scope in the params view and place the source editor containing + // the raw post data directly underneath. + $("#request-params-box").removeAttribute("flex"); + let paramsScope = this._params.addScope(this._paramsPostPayload); + paramsScope.expanded = true; + paramsScope.locked = true; + + $("#request-post-data-textarea-box").hidden = false; + NetMonitorView.editor("#request-post-data-textarea").then((aEditor) => { + aEditor.setText(aString); + }); + } + window.emit("NetMonitor:ResponsePostParamsAvailable"); + }); + }, + + /** + * Populates the params container in this view with the specified data. + * + * @param string aName + * The type of params to populate (get or post). + * @param string aParams + * A query string of params (e.g. "?foo=bar&baz=42"). + */ + _addParams: function(aName, aParams) { + // Make sure there's at least one param available. + if (!aParams || !aParams.contains("=")) { + return; + } + // Turn the params string into an array containing { name: value } tuples. + let paramsArray = aParams.replace(/^[?&]/, "").split("&").map((e) => + let (param = e.split("=")) { + name: NetworkHelper.convertToUnicode(unescape(param[0])), + value: NetworkHelper.convertToUnicode(unescape(param[1])) + }); + + let paramsScope = this._params.addScope(aName); + paramsScope.expanded = true; + + for (let param of paramsArray) { + let headerVar = paramsScope.addItem(param.name, {}, true); + headerVar.setGrip(param.value); + } + }, + + /** + * Sets the network response body shown in this view. + * + * @param string aUrl + * The request's url. + * @param object aResponse + * The message received from the server. + */ + _setResponseBody: function(aUrl, aResponse) { + if (!aResponse) { + return; + } + let { mimeType, text, encoding } = aResponse.content; + + gNetwork.getString(text).then((aString) => { + // Handle json. + if (mimeType.contains("/json")) { + let jsonpRegex = /^[a-zA-Z0-9_$]+\(|\)$/g; // JSONP with callback. + let sanitizedJSON = aString.replace(jsonpRegex, ""); + let callbackPadding = aString.match(jsonpRegex); + + // Make sure this is an valid JSON object first. If so, nicely display + // the parsing results in a variables view. Otherwise, simply show + // the contents as plain text. + try { + var jsonObject = JSON.parse(sanitizedJSON); + } catch (e) { + var parsingError = e; + } + + // Valid JSON. + if (jsonObject) { + $("#response-content-json-box").hidden = false; + let jsonScopeName = callbackPadding + ? L10N.getFormatStr("jsonpScopeName", callbackPadding[0].slice(0, -1)) + : L10N.getStr("jsonScopeName"); + + let jsonScope = this._json.addScope(jsonScopeName); + jsonScope.addItem().populate(jsonObject, { expanded: true }); + jsonScope.expanded = true; + } + // Malformed JSON. + else { + $("#response-content-textarea-box").hidden = false; + NetMonitorView.editor("#response-content-textarea").then((aEditor) => { + aEditor.setMode(SourceEditor.MODES.JAVASCRIPT); + aEditor.setText(aString); + }); + let infoHeader = $("#response-content-info-header"); + infoHeader.setAttribute("value", parsingError); + infoHeader.setAttribute("tooltiptext", parsingError); + infoHeader.hidden = false; + } + } + // Handle images. + else if (mimeType.contains("image/")) { + $("#response-content-image-box").setAttribute("align", "center"); + $("#response-content-image-box").setAttribute("pack", "center"); + $("#response-content-image-box").hidden = false; + $("#response-content-image").src = + "data:" + mimeType + ";" + encoding + "," + aString; + + // Immediately display additional information about the image: + // file name, mime type and encoding. + $("#response-content-image-name-value").setAttribute("value", nsIURL(aUrl).fileName); + $("#response-content-image-mime-value").setAttribute("value", mimeType); + $("#response-content-image-encoding-value").setAttribute("value", encoding); + + // Wait for the image to load in order to display the width and height. + $("#response-content-image").onload = (e) => { + // XUL images are majestic so they don't bother storing their dimensions + // in width and height attributes like the rest of the folk. Hack around + // this by getting the bounding client rect and subtracting the margins. + let { width, height } = e.target.getBoundingClientRect(); + let dimensions = (width - 2) + " x " + (height - 2); + $("#response-content-image-dimensions-value").setAttribute("value", dimensions); + }; + } + // Handle anything else. + else { + $("#response-content-textarea-box").hidden = false; + NetMonitorView.editor("#response-content-textarea").then((aEditor) => { + aEditor.setMode(SourceEditor.MODES.TEXT); + aEditor.setText(aString); + + // Maybe set a more appropriate mode in the Source Editor if possible, + // but avoid doing this for very large files. + if (aString.length < SOURCE_SYNTAX_HIGHLIGHT_MAX_FILE_SIZE) { + for (let key in CONTENT_MIME_TYPE_MAPPINGS) { + if (mimeType.contains(key)) { + aEditor.setMode(CONTENT_MIME_TYPE_MAPPINGS[key]); + break; + } + } + } + }); + } + window.emit("NetMonitor:ResponseBodyAvailable"); + }); + }, + + /** + * Sets the timings information shown in this view. + * + * @param object aResponse + * The message received from the server. + */ + _setTimingsInformation: function(aResponse) { + if (!aResponse) { + return; + } + let { blocked, dns, connect, send, wait, receive } = aResponse.timings; + + let tabboxWidth = $("#details-pane").getAttribute("width"); + let availableWidth = tabboxWidth / 2; // Other nodes also take some space. + let scale = Math.max(availableWidth / aResponse.totalTime, 0); + + $("#timings-summary-blocked .requests-menu-timings-box") + .setAttribute("width", blocked * scale); + $("#timings-summary-blocked .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", blocked)); + + $("#timings-summary-dns .requests-menu-timings-box") + .setAttribute("width", dns * scale); + $("#timings-summary-dns .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", dns)); + + $("#timings-summary-connect .requests-menu-timings-box") + .setAttribute("width", connect * scale); + $("#timings-summary-connect .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", connect)); + + $("#timings-summary-send .requests-menu-timings-box") + .setAttribute("width", send * scale); + $("#timings-summary-send .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", send)); + + $("#timings-summary-wait .requests-menu-timings-box") + .setAttribute("width", wait * scale); + $("#timings-summary-wait .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", wait)); + + $("#timings-summary-receive .requests-menu-timings-box") + .setAttribute("width", receive * scale); + $("#timings-summary-receive .requests-menu-timings-total") + .setAttribute("value", L10N.getFormatStr("networkMenu.totalMS", receive)); + + $("#timings-summary-dns .requests-menu-timings-box") + .style.transform = "translateX(" + (scale * blocked) + "px)"; + $("#timings-summary-connect .requests-menu-timings-box") + .style.transform = "translateX(" + (scale * (blocked + dns)) + "px)"; + $("#timings-summary-send .requests-menu-timings-box") + .style.transform = "translateX(" + (scale * (blocked + dns + connect)) + "px)"; + $("#timings-summary-wait .requests-menu-timings-box") + .style.transform = "translateX(" + (scale * (blocked + dns + connect + send)) + "px)"; + $("#timings-summary-receive .requests-menu-timings-box") + .style.transform = "translateX(" + (scale * (blocked + dns + connect + send + wait)) + "px)"; + + $("#timings-summary-dns .requests-menu-timings-total") + .style.transform = "translateX(" + (scale * blocked) + "px)"; + $("#timings-summary-connect .requests-menu-timings-total") + .style.transform = "translateX(" + (scale * (blocked + dns)) + "px)"; + $("#timings-summary-send .requests-menu-timings-total") + .style.transform = "translateX(" + (scale * (blocked + dns + connect)) + "px)"; + $("#timings-summary-wait .requests-menu-timings-total") + .style.transform = "translateX(" + (scale * (blocked + dns + connect + send)) + "px)"; + $("#timings-summary-receive .requests-menu-timings-total") + .style.transform = "translateX(" + (scale * (blocked + dns + connect + send + wait)) + "px)"; + }, + + _dataSrc: null, + _headers: null, + _cookies: null, + _params: null, + _json: null, + _paramsQueryString: "", + _paramsFormData: "", + _paramsPostPayload: "", + _requestHeaders: "", + _responseHeaders: "", + _requestCookies: "", + _responseCookies: "" +}; + +/** + * DOM query helper. + */ +function $(aSelector, aTarget = document) aTarget.querySelector(aSelector); + +/** + * Helper for getting an nsIURL instance out of a string. + */ +function nsIURL(aUrl, aStore = nsIURL.store) { + if (aStore.has(aUrl)) { + return aStore.get(aUrl); + } + let uri = Services.io.newURI(aUrl, null, null).QueryInterface(Ci.nsIURL); + aStore.set(aUrl, uri); + return uri; +} +nsIURL.store = new Map(); + +/** + * Helper for draining a rapid succession of events and invoking a callback + * once everything settles down. + */ +function drain(aId, aWait, aCallback, aStore = drain.store) { + window.clearTimeout(aStore.get(aId)); + aStore.set(aId, window.setTimeout(aCallback, aWait)); +} +drain.store = new Map(); + +/** + * Preliminary setup for the NetMonitorView object. + */ +NetMonitorView.Toolbar = new ToolbarView(); +NetMonitorView.RequestsMenu = new RequestsMenuView(); +NetMonitorView.NetworkDetails = new NetworkDetailsView(); |