/* 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/. */ /* globals document, window, dumpn, $, gNetwork, EVENTS, Prefs, NetMonitorController, NetMonitorView */ "use strict"; /* eslint-disable mozilla/reject-some-requires */ const { Cu } = require("chrome"); const {Task} = require("devtools/shared/task"); const {DeferredTask} = Cu.import("resource://gre/modules/DeferredTask.jsm", {}); /* eslint-disable mozilla/reject-some-requires */ const {SideMenuWidget} = require("resource://devtools/client/shared/widgets/SideMenuWidget.jsm"); const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip"); const {setImageTooltip, getImageDimensions} = require("devtools/client/shared/widgets/tooltip/ImageTooltipHelper"); const {Heritage, WidgetMethods, setNamedTimeout} = require("devtools/client/shared/widgets/view-helpers"); const {CurlUtils} = require("devtools/client/shared/curl"); const {Filters, isFreetextMatch} = require("./filter-predicates"); const {Sorters} = require("./sort-predicates"); const {L10N, WEBCONSOLE_L10N} = require("./l10n"); const {formDataURI, writeHeaderText, getKeyWithEvent, getAbbreviatedMimeType, getUriNameWithQuery, getUriHostPort, getUriHost, loadCauseString} = require("./request-utils"); const Actions = require("./actions/index"); const RequestListContextMenu = require("./request-list-context-menu"); loader.lazyRequireGetter(this, "NetworkHelper", "devtools/shared/webconsole/network-helper"); const HTML_NS = "http://www.w3.org/1999/xhtml"; const EPSILON = 0.001; // ms const RESIZE_REFRESH_RATE = 50; // ms const REQUESTS_REFRESH_RATE = 50; // tooltip show/hide delay in ms const REQUESTS_TOOLTIP_TOGGLE_DELAY = 500; // px const REQUESTS_TOOLTIP_IMAGE_MAX_DIM = 400; // px const REQUESTS_TOOLTIP_STACK_TRACE_WIDTH = 600; // px const REQUESTS_WATERFALL_SAFE_BOUNDS = 90; // ms const REQUESTS_WATERFALL_HEADER_TICKS_MULTIPLE = 5; // px const REQUESTS_WATERFALL_HEADER_TICKS_SPACING_MIN = 60; // ms const REQUESTS_WATERFALL_BACKGROUND_TICKS_MULTIPLE = 5; const REQUESTS_WATERFALL_BACKGROUND_TICKS_SCALES = 3; // px const REQUESTS_WATERFALL_BACKGROUND_TICKS_SPACING_MIN = 10; 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; const REQUESTS_WATERFALL_DOMCONTENTLOADED_TICKS_COLOR_RGBA = [255, 0, 0, 128]; const REQUESTS_WATERFALL_LOAD_TICKS_COLOR_RGBA = [0, 0, 255, 128]; // Constants for formatting bytes. const BYTES_IN_KB = 1024; const BYTES_IN_MB = Math.pow(BYTES_IN_KB, 2); const BYTES_IN_GB = Math.pow(BYTES_IN_KB, 3); const MAX_BYTES_SIZE = 1000; const MAX_KB_SIZE = 1000 * BYTES_IN_KB; const MAX_MB_SIZE = 1000 * BYTES_IN_MB; // TODO: duplicated from netmonitor-view.js. Move to a format-utils.js module. const REQUEST_TIME_DECIMALS = 2; const CONTENT_SIZE_DECIMALS = 2; const CONTENT_MIME_TYPE_ABBREVIATIONS = { "ecmascript": "js", "javascript": "js", "x-javascript": "js" }; // A smart store watcher to notify store changes as necessary function storeWatcher(initialValue, reduceValue, onChange) { let currentValue = initialValue; return () => { const oldValue = currentValue; const newValue = reduceValue(currentValue); if (newValue !== oldValue) { currentValue = newValue; onChange(newValue, oldValue); } }; } /** * 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._onHover = this._onHover.bind(this); this._onSelect = this._onSelect.bind(this); this._onSwap = this._onSwap.bind(this); this._onResize = this._onResize.bind(this); this._onScroll = this._onScroll.bind(this); this._onSecurityIconClick = this._onSecurityIconClick.bind(this); } RequestsMenuView.prototype = Heritage.extend(WidgetMethods, { /** * Initialization function, called when the network monitor is started. */ initialize: function (store) { dumpn("Initializing the RequestsMenuView"); this.store = store; this.contextMenu = new RequestListContextMenu(); let widgetParentEl = $("#requests-menu-contents"); this.widget = new SideMenuWidget(widgetParentEl); this._splitter = $("#network-inspector-view-splitter"); // Create a tooltip for the newly appended network request item. this.tooltip = new HTMLTooltip(NetMonitorController._toolbox.doc, { type: "arrow" }); this.tooltip.startTogglingOnHover(widgetParentEl, this._onHover, { toggleDelay: REQUESTS_TOOLTIP_TOGGLE_DELAY, interactive: true }); this.sortContents((a, b) => Sorters.waterfall(a.attachment, b.attachment)); this.allowFocusOnRightClick = true; this.maintainSelectionVisible = true; this.widget.addEventListener("select", this._onSelect, false); this.widget.addEventListener("swap", this._onSwap, false); this._splitter.addEventListener("mousemove", this._onResize, false); window.addEventListener("resize", this._onResize, false); this.requestsMenuSortEvent = getKeyWithEvent(this.sortBy.bind(this)); this.requestsMenuSortKeyboardEvent = getKeyWithEvent(this.sortBy.bind(this), true); this._onContextMenu = this._onContextMenu.bind(this); this._onContextPerfCommand = () => NetMonitorView.toggleFrontendMode(); this._onReloadCommand = () => NetMonitorView.reloadPage(); this._flushRequestsTask = new DeferredTask(this._flushRequests, REQUESTS_REFRESH_RATE); this.sendCustomRequestEvent = this.sendCustomRequest.bind(this); this.closeCustomRequestEvent = this.closeCustomRequest.bind(this); this.cloneSelectedRequestEvent = this.cloneSelectedRequest.bind(this); this.toggleRawHeadersEvent = this.toggleRawHeaders.bind(this); this.reFilterRequests = this.reFilterRequests.bind(this); $("#toolbar-labels").addEventListener("click", this.requestsMenuSortEvent, false); $("#toolbar-labels").addEventListener("keydown", this.requestsMenuSortKeyboardEvent, false); $("#toggle-raw-headers").addEventListener("click", this.toggleRawHeadersEvent, false); $("#requests-menu-contents").addEventListener("scroll", this._onScroll, true); $("#requests-menu-contents").addEventListener("contextmenu", this._onContextMenu); this.unsubscribeStore = store.subscribe(storeWatcher( null, () => store.getState().filters, (newFilters) => { this._activeFilters = newFilters.types .toSeq() .filter((checked, key) => checked) .keySeq() .toArray(); this._currentFreetextFilter = newFilters.url; this.reFilterRequests(); } )); Prefs.filters.forEach(type => store.dispatch(Actions.toggleFilterType(type))); window.once("connected", this._onConnect.bind(this)); }, _onConnect: function () { $("#requests-menu-reload-notice-button").addEventListener("command", this._onReloadCommand, false); if (NetMonitorController.supportsCustomRequest) { $("#custom-request-send-button").addEventListener("click", this.sendCustomRequestEvent, false); $("#custom-request-close-button").addEventListener("click", this.closeCustomRequestEvent, false); $("#headers-summary-resend").addEventListener("click", this.cloneSelectedRequestEvent, false); } else { $("#headers-summary-resend").hidden = true; } if (NetMonitorController.supportsPerfStats) { $("#requests-menu-perf-notice-button").addEventListener("command", this._onContextPerfCommand, false); $("#network-statistics-back-button").addEventListener("command", this._onContextPerfCommand, false); } else { $("#notice-perf-message").hidden = true; } if (!NetMonitorController.supportsTransferredResponseSize) { $("#requests-menu-transferred-header-box").hidden = true; $("#requests-menu-item-template .requests-menu-transferred") .hidden = true; } }, /** * Destruction function, called when the network monitor is closed. */ destroy: function () { dumpn("Destroying the RequestsMenuView"); Prefs.filters = this._activeFilters; /* Destroy the tooltip */ this.tooltip.stopTogglingOnHover(); this.tooltip.destroy(); $("#requests-menu-contents").removeEventListener("scroll", this._onScroll, true); $("#requests-menu-contents").removeEventListener("contextmenu", this._onContextMenu); this.widget.removeEventListener("select", this._onSelect, false); this.widget.removeEventListener("swap", this._onSwap, false); this._splitter.removeEventListener("mousemove", this._onResize, false); window.removeEventListener("resize", this._onResize, false); $("#toolbar-labels").removeEventListener("click", this.requestsMenuSortEvent, false); $("#toolbar-labels").removeEventListener("keydown", this.requestsMenuSortKeyboardEvent, false); this._flushRequestsTask.disarm(); $("#requests-menu-reload-notice-button").removeEventListener("command", this._onReloadCommand, false); $("#requests-menu-perf-notice-button").removeEventListener("command", this._onContextPerfCommand, false); $("#network-statistics-back-button").removeEventListener("command", this._onContextPerfCommand, false); $("#custom-request-send-button").removeEventListener("click", this.sendCustomRequestEvent, false); $("#custom-request-close-button").removeEventListener("click", this.closeCustomRequestEvent, false); $("#headers-summary-resend").removeEventListener("click", this.cloneSelectedRequestEvent, false); $("#toggle-raw-headers").removeEventListener("click", this.toggleRawHeadersEvent, false); this.unsubscribeStore(); }, /** * Resets this container (removes all the networking information). */ reset: function () { this.empty(); this._addQueue = []; this._updateQueue = []; this._firstRequestStartedMillis = -1; this._lastRequestEndedMillis = -1; }, /** * Specifies if this view may be updated lazily. */ _lazyUpdate: true, get lazyUpdate() { return this._lazyUpdate; }, set lazyUpdate(value) { this._lazyUpdate = value; if (!value) { this._flushRequests(); } }, /** * Adds a network request to this container. * * @param string id * An identifier coming from the network monitor controller. * @param string startedDateTime * 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 method * Specifies the request method (e.g. "GET", "POST", etc.) * @param string url * Specifies the request's url. * @param boolean isXHR * True if this request was initiated via XHR. * @param object cause * Specifies the request's cause. Has the following properties: * - type: nsContentPolicyType constant * - loadingDocumentUri: URI of the request origin * - stacktrace: JS stacktrace of the request * @param boolean fromCache * Indicates if the result came from the browser cache * @param boolean fromServiceWorker * Indicates if the request has been intercepted by a Service Worker */ addRequest: function (id, startedDateTime, method, url, isXHR, cause, fromCache, fromServiceWorker) { this._addQueue.push([id, startedDateTime, method, url, isXHR, cause, fromCache, fromServiceWorker]); // Lazy updating is disabled in some tests. if (!this.lazyUpdate) { return void this._flushRequests(); } this._flushRequestsTask.arm(); return undefined; }, /** * Create a new custom request form populated with the data from * the currently selected request. */ cloneSelectedRequest: function () { let selected = this.selectedItem.attachment; // Create the element node for the network request item. let menuView = this._createMenuView(selected.method, selected.url, selected.cause); // Append a network request item to this container. let newItem = this.push([menuView], { attachment: Object.create(selected, { isCustom: { value: true } }) }); // Immediately switch to new request pane. this.selectedItem = newItem; }, /** * Send a new HTTP request using the data in the custom request form. */ sendCustomRequest: function () { let selected = this.selectedItem.attachment; let data = { url: selected.url, method: selected.method, httpVersion: selected.httpVersion, }; if (selected.requestHeaders) { data.headers = selected.requestHeaders.headers; } if (selected.requestPostData) { data.body = selected.requestPostData.postData.text; } NetMonitorController.webConsoleClient.sendHTTPRequest(data, response => { let id = response.eventActor.actor; this._preferredItemId = id; }); this.closeCustomRequest(); }, /** * Remove the currently selected custom request. */ closeCustomRequest: function () { this.remove(this.selectedItem); NetMonitorView.Sidebar.toggle(false); }, /** * Shows raw request/response headers in textboxes. */ toggleRawHeaders: function () { let requestTextarea = $("#raw-request-headers-textarea"); let responseTextare = $("#raw-response-headers-textarea"); let rawHeadersHidden = $("#raw-headers").getAttribute("hidden"); if (rawHeadersHidden) { let selected = this.selectedItem.attachment; let selectedRequestHeaders = selected.requestHeaders.headers; let selectedResponseHeaders = selected.responseHeaders.headers; requestTextarea.value = writeHeaderText(selectedRequestHeaders); responseTextare.value = writeHeaderText(selectedResponseHeaders); $("#raw-headers").hidden = false; } else { requestTextarea.value = null; responseTextare.value = null; $("#raw-headers").hidden = true; } }, /** * Refreshes the view contents with the newly selected filters */ reFilterRequests: function () { this.filterContents(this._filterPredicate); this.updateRequests(); this.refreshZebra(); }, /** * Returns a predicate that can be used to test if a request matches any of * the active filters. */ get _filterPredicate() { let currentFreetextFilter = this._currentFreetextFilter; return requestItem => { const { attachment } = requestItem; return this._activeFilters.some(filterName => Filters[filterName](attachment)) && isFreetextMatch(attachment, currentFreetextFilter); }; }, /** * Sorts all network requests in this container by a specified detail. * * @param string type * Either "status", "method", "file", "domain", "type", "transferred", * "size" or "waterfall". */ sortBy: function (type = "waterfall") { let target = $("#requests-menu-" + type + "-button"); let headers = document.querySelectorAll(".requests-menu-header-button"); for (let header of headers) { if (header != target) { header.removeAttribute("sorted"); header.removeAttribute("tooltiptext"); header.parentNode.removeAttribute("active"); } } 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")); } // Used to style the next column. target.parentNode.setAttribute("active", "true"); } // Sort by whatever was requested. switch (type) { case "status": if (direction == "ascending") { this.sortContents((a, b) => Sorters.status(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.status(a.attachment, b.attachment)); } break; case "method": if (direction == "ascending") { this.sortContents((a, b) => Sorters.method(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.method(a.attachment, b.attachment)); } break; case "file": if (direction == "ascending") { this.sortContents((a, b) => Sorters.file(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.file(a.attachment, b.attachment)); } break; case "domain": if (direction == "ascending") { this.sortContents((a, b) => Sorters.domain(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.domain(a.attachment, b.attachment)); } break; case "cause": if (direction == "ascending") { this.sortContents((a, b) => Sorters.cause(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.cause(a.attachment, b.attachment)); } break; case "type": if (direction == "ascending") { this.sortContents((a, b) => Sorters.type(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.type(a.attachment, b.attachment)); } break; case "transferred": if (direction == "ascending") { this.sortContents((a, b) => Sorters.transferred(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.transferred(a.attachment, b.attachment)); } break; case "size": if (direction == "ascending") { this.sortContents((a, b) => Sorters.size(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.size(a.attachment, b.attachment)); } break; case "waterfall": if (direction == "ascending") { this.sortContents((a, b) => Sorters.waterfall(a.attachment, b.attachment)); } else { this.sortContents((a, b) => -Sorters.waterfall(a.attachment, b.attachment)); } break; } this.updateRequests(); this.refreshZebra(); }, /** * Removes all network requests and closes the sidebar if open. */ clear: function () { NetMonitorController.NetworkEventsHandler.clearMarkers(); NetMonitorView.Sidebar.toggle(false); $("#requests-menu-empty-notice").hidden = false; this.empty(); this.updateRequests(); }, /** * Update store request itmes and trigger related UI update */ updateRequests: function () { this.store.dispatch(Actions.updateRequests(this.visibleItems)); }, /** * Adds odd/even attributes to all the visible items in this container. */ refreshZebra: function () { let visibleItems = this.visibleItems; 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"); } } }, /** * Attaches security icon click listener for the given request menu item. * * @param object item * The network request item to attach the listener to. */ attachSecurityIconClickListener: function ({ target }) { let icon = $(".requests-security-state-icon", target); icon.addEventListener("click", this._onSecurityIconClick); }, /** * Schedules adding additional information to a network request. * * @param string id * An identifier coming from the network monitor controller. * @param object data * An object containing several { key: value } tuples of network info. * Supported keys are "httpVersion", "status", "statusText" etc. * @param function callback * A function to call once the request has been updated in the view. */ updateRequest: function (id, data, callback) { this._updateQueue.push([id, data, callback]); // Lazy updating is disabled in some tests. if (!this.lazyUpdate) { return void this._flushRequests(); } this._flushRequestsTask.arm(); return undefined; }, /** * Starts adding all queued additional information about network requests. */ _flushRequests: function () { // Prevent displaying any updates received after the target closed. if (NetMonitorView._isDestroyed) { return; } let widget = NetMonitorView.RequestsMenu.widget; let isScrolledToBottom = widget.isScrolledToBottom(); for (let [id, startedDateTime, method, url, isXHR, cause, fromCache, fromServiceWorker] of this._addQueue) { // Convert the received date/time string to a unix timestamp. let unixTime = Date.parse(startedDateTime); // Create the element node for the network request item. let menuView = this._createMenuView(method, url, cause); // 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, id], { attachment: { startedDeltaMillis: unixTime - this._firstRequestStartedMillis, startedMillis: unixTime, method: method, url: url, isXHR: isXHR, cause: cause, fromCache: fromCache, fromServiceWorker: fromServiceWorker } }); if (id == this._preferredItemId) { this.selectedItem = requestItem; } window.emit(EVENTS.REQUEST_ADDED, id); } if (isScrolledToBottom && this._addQueue.length) { widget.scrollToBottom(); } // 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, callback] 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 val = data[key]; if (val === undefined) { // The information in the packet is empty, it can be safely ignored. continue; } switch (key) { case "requestHeaders": requestItem.attachment.requestHeaders = val; break; case "requestCookies": requestItem.attachment.requestCookies = val; break; case "requestPostData": // Search the POST data upload stream for request headers and add // them to a separate store, different from the classic headers. // XXX: Be really careful here! We're creating a function inside // a loop, so remember the actual request item we want to modify. let currentItem = requestItem; let currentStore = { headers: [], headersSize: 0 }; Task.spawn(function* () { let postData = yield gNetwork.getString(val.postData.text); let payloadHeaders = CurlUtils.getHeadersFromMultipartText( postData); currentStore.headers = payloadHeaders; currentStore.headersSize = payloadHeaders.reduce( (acc, { name, value }) => acc + name.length + value.length + 2, 0); // The `getString` promise is async, so we need to refresh the // information displayed in the network details pane again here. refreshNetworkDetailsPaneIfNecessary(currentItem); }); requestItem.attachment.requestPostData = val; requestItem.attachment.requestHeadersFromUploadStream = currentStore; break; case "securityState": requestItem.attachment.securityState = val; this.updateMenuView(requestItem, key, val); break; case "securityInfo": requestItem.attachment.securityInfo = val; break; case "responseHeaders": requestItem.attachment.responseHeaders = val; break; case "responseCookies": requestItem.attachment.responseCookies = val; break; case "httpVersion": requestItem.attachment.httpVersion = val; break; case "remoteAddress": requestItem.attachment.remoteAddress = val; this.updateMenuView(requestItem, key, val); break; case "remotePort": requestItem.attachment.remotePort = val; break; case "status": requestItem.attachment.status = val; this.updateMenuView(requestItem, key, { status: val, cached: requestItem.attachment.fromCache, serviceWorker: requestItem.attachment.fromServiceWorker }); break; case "statusText": requestItem.attachment.statusText = val; let text = (requestItem.attachment.status + " " + requestItem.attachment.statusText); if (requestItem.attachment.fromCache) { text += " (cached)"; } else if (requestItem.attachment.fromServiceWorker) { text += " (service worker)"; } this.updateMenuView(requestItem, key, text); break; case "headersSize": requestItem.attachment.headersSize = val; break; case "contentSize": requestItem.attachment.contentSize = val; this.updateMenuView(requestItem, key, val); break; case "transferredSize": if (requestItem.attachment.fromCache) { requestItem.attachment.transferredSize = 0; this.updateMenuView(requestItem, key, "cached"); } else if (requestItem.attachment.fromServiceWorker) { requestItem.attachment.transferredSize = 0; this.updateMenuView(requestItem, key, "service worker"); } else { requestItem.attachment.transferredSize = val; this.updateMenuView(requestItem, key, val); } break; case "mimeType": requestItem.attachment.mimeType = val; this.updateMenuView(requestItem, key, val); break; case "responseContent": // 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"); } requestItem.attachment.responseContent = val; this.updateMenuView(requestItem, key, val); break; case "totalTime": requestItem.attachment.totalTime = val; requestItem.attachment.endedMillis = requestItem.attachment.startedMillis + val; this.updateMenuView(requestItem, key, val); this._registerLastRequestEnd(requestItem.attachment.endedMillis); break; case "eventTimings": requestItem.attachment.eventTimings = val; this._createWaterfallView( requestItem, val.timings, requestItem.attachment.fromCache || requestItem.attachment.fromServiceWorker ); break; } } refreshNetworkDetailsPaneIfNecessary(requestItem); if (callback) { callback(); } } /** * Refreshes the information displayed in the sidebar, in case this update * may have additional information about a request which isn't shown yet * in the network details pane. * * @param object requestItem * The item to repopulate the sidebar with in case it's selected in * this requests menu. */ function refreshNetworkDetailsPaneIfNecessary(requestItem) { let selectedItem = NetMonitorView.RequestsMenu.selectedItem; if (selectedItem == requestItem) { NetMonitorView.NetworkDetails.populate(selectedItem.attachment); } } // We're done flushing all the requests, clear the update queue. this._updateQueue = []; this._addQueue = []; $("#requests-menu-empty-notice").hidden = !!this.itemCount; // 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.updateRequests(); this.refreshZebra(); // Rescale all the waterfalls so that everything is visible at once. this._flushWaterfallViews(); }, /** * Customization function for creating an item's UI. * * @param string method * Specifies the request method (e.g. "GET", "POST", etc.) * @param string url * Specifies the request's url. * @param object cause * Specifies the request's cause. Has two properties: * - type: nsContentPolicyType constant * - uri: URI of the request origin * @return nsIDOMNode * The network request view. */ _createMenuView: function (method, url, cause) { let template = $("#requests-menu-item-template"); let fragment = document.createDocumentFragment(); // Flatten the DOM by removing one redundant box (the template container). for (let node of template.childNodes) { fragment.appendChild(node.cloneNode(true)); } this.updateMenuView(fragment, "method", method); this.updateMenuView(fragment, "url", url); this.updateMenuView(fragment, "cause", cause); return fragment; }, /** * Get a human-readable string from a number of bytes, with the B, KB, MB, or * GB value. Note that the transition between abbreviations is by 1000 rather * than 1024 in order to keep the displayed digits smaller as "1016 KB" is * more awkward than 0.99 MB" */ getFormattedSize(bytes) { if (bytes < MAX_BYTES_SIZE) { return L10N.getFormatStr("networkMenu.sizeB", bytes); } else if (bytes < MAX_KB_SIZE) { let kb = bytes / BYTES_IN_KB; let size = L10N.numberWithDecimals(kb, CONTENT_SIZE_DECIMALS); return L10N.getFormatStr("networkMenu.sizeKB", size); } else if (bytes < MAX_MB_SIZE) { let mb = bytes / BYTES_IN_MB; let size = L10N.numberWithDecimals(mb, CONTENT_SIZE_DECIMALS); return L10N.getFormatStr("networkMenu.sizeMB", size); } let gb = bytes / BYTES_IN_GB; let size = L10N.numberWithDecimals(gb, CONTENT_SIZE_DECIMALS); return L10N.getFormatStr("networkMenu.sizeGB", size); }, /** * Updates the information displayed in a network request item view. * * @param object item * The network request item in this container. * @param string key * The type of information that is to be updated. * @param any value * The new value to be shown. * @return object * A promise that is resolved once the information is displayed. */ updateMenuView: Task.async(function* (item, key, value) { let target = item.target || item; switch (key) { case "method": { let node = $(".requests-menu-method", target); node.setAttribute("value", value); break; } case "url": { let uri; try { uri = NetworkHelper.nsIURL(value); } catch (e) { // User input may not make a well-formed url yet. break; } let nameWithQuery = getUriNameWithQuery(uri); let hostPort = getUriHostPort(uri); let host = getUriHost(uri); let unicodeUrl = NetworkHelper.convertToUnicode(unescape(uri.spec)); let file = $(".requests-menu-file", target); file.setAttribute("value", nameWithQuery); file.setAttribute("tooltiptext", unicodeUrl); let domain = $(".requests-menu-domain", target); domain.setAttribute("value", hostPort); domain.setAttribute("tooltiptext", hostPort); // Mark local hosts specially, where "local" is as defined in the W3C // spec for secure contexts. // http://www.w3.org/TR/powerful-features/ // // * If the name falls under 'localhost' // * If the name is an IPv4 address within 127.0.0.0/8 // * If the name is an IPv6 address within ::1/128 // // IPv6 parsing is a little sloppy; it assumes that the address has // been validated before it gets here. let icon = $(".requests-security-state-icon", target); icon.classList.remove("security-state-local"); if (host.match(/(.+\.)?localhost$/) || host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}/) || host.match(/\[[0:]+1\]/)) { let tooltip = L10N.getStr("netmonitor.security.state.secure"); icon.classList.add("security-state-local"); icon.setAttribute("tooltiptext", tooltip); } break; } case "remoteAddress": let domain = $(".requests-menu-domain", target); let tooltip = (domain.getAttribute("value") + (value ? " (" + value + ")" : "")); domain.setAttribute("tooltiptext", tooltip); break; case "securityState": { let icon = $(".requests-security-state-icon", target); this.attachSecurityIconClickListener(item); // Security icon for local hosts is set in the "url" branch if (icon.classList.contains("security-state-local")) { break; } let tooltip2 = L10N.getStr("netmonitor.security.state." + value); icon.classList.add("security-state-" + value); icon.setAttribute("tooltiptext", tooltip2); break; } case "status": { let node = $(".requests-menu-status-icon", target); // "code" attribute is only used by css to determine the icon color let code; if (value.cached) { code = "cached"; } else if (value.serviceWorker) { code = "service worker"; } else { code = value.status; } node.setAttribute("code", code); let codeNode = $(".requests-menu-status-code", target); codeNode.setAttribute("value", value.status); break; } case "statusText": { let node = $(".requests-menu-status", target); node.setAttribute("tooltiptext", value); break; } case "cause": { let labelNode = $(".requests-menu-cause-label", target); labelNode.setAttribute("value", loadCauseString(value.type)); if (value.loadingDocumentUri) { labelNode.setAttribute("tooltiptext", value.loadingDocumentUri); } let stackNode = $(".requests-menu-cause-stack", target); if (value.stacktrace && value.stacktrace.length > 0) { stackNode.removeAttribute("hidden"); } break; } case "contentSize": { let node = $(".requests-menu-size", target); let text = this.getFormattedSize(value); node.setAttribute("value", text); node.setAttribute("tooltiptext", text); break; } case "transferredSize": { let node = $(".requests-menu-transferred", target); let text; if (value === null) { text = L10N.getStr("networkMenu.sizeUnavailable"); } else if (value === "cached") { text = L10N.getStr("networkMenu.sizeCached"); node.classList.add("theme-comment"); } else if (value === "service worker") { text = L10N.getStr("networkMenu.sizeServiceWorker"); node.classList.add("theme-comment"); } else { text = this.getFormattedSize(value); } node.setAttribute("value", text); node.setAttribute("tooltiptext", text); break; } case "mimeType": { let type = getAbbreviatedMimeType(value); let node = $(".requests-menu-type", target); let text = CONTENT_MIME_TYPE_ABBREVIATIONS[type] || type; node.setAttribute("value", text); node.setAttribute("tooltiptext", value); break; } case "responseContent": { let { mimeType } = item.attachment; if (mimeType.includes("image/")) { let { text, encoding } = value.content; let responseBody = yield gNetwork.getString(text); let node = $(".requests-menu-icon", item.target); node.src = formDataURI(mimeType, encoding, responseBody); node.setAttribute("type", "thumbnail"); node.removeAttribute("hidden"); window.emit(EVENTS.RESPONSE_IMAGE_THUMBNAIL_DISPLAYED); } break; } case "totalTime": { let node = $(".requests-menu-timings-total", target); // integer let text = L10N.getFormatStr("networkMenu.totalMS", value); node.setAttribute("value", text); node.setAttribute("tooltiptext", text); break; } } }), /** * Creates a waterfall representing timing information in a network * request item view. * * @param object item * The network request item in this container. * @param object timings * An object containing timing information. * @param boolean fromCache * Indicates if the result came from the browser cache or * a service worker */ _createWaterfallView: function (item, timings, fromCache) { let { target } = item; let sections = ["blocked", "dns", "connect", "ssl", "send", "wait", "receive"]; // Skipping "blocked" because it doesn't work yet. let timingsNode = $(".requests-menu-timings", target); let timingsTotal = $(".requests-menu-timings-total", timingsNode); if (fromCache) { timingsTotal.style.display = "none"; return; } // Add a set of boxes representing timing information. for (let key of sections) { let width = timings[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, timingsTotal); } } }, /** * Rescales and redraws all the waterfall views in this container. * * @param boolean reset * True if this container's width was changed. */ _flushWaterfallViews: function (reset) { // Don't paint things while the waterfall view isn't even visible, // or there are no items added to this container. if (NetMonitorView.currentFrontendMode != "network-inspector-view" || !this.itemCount) { return; } // 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 (reset) { this._cachedWaterfallWidth = 0; } // 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); // Apply CSS transforms to each waterfall in this container totalTime // accurately translate and resize as needed. for (let { target, attachment } of this) { let timingsNode = $(".requests-menu-timings", 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; totalNode.style.transform = revScaleX; } }, /** * Creates the labels displayed on the waterfall header in this container. * * @param number scale * The current waterfall scale. */ _showWaterfallDivisionLabels: function (scale) { let container = $("#requests-menu-waterfall-label-wrapper"); 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 = scale * 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 translateX = "translateX(" + ((direction * x) | 0) + "px)"; let millisecondTime = x / scale; let normalizedTime = millisecondTime; let divisionScale = "millisecond"; // If the division is greater than 1 minute. if (normalizedTime > 60000) { normalizedTime /= 60000; divisionScale = "minute"; } else if (normalizedTime > 1000) { // If the division is greater than 1 second. normalizedTime /= 1000; divisionScale = "second"; } // Showing too many decimals is bad UX. if (divisionScale == "millisecond") { normalizedTime |= 0; } else { normalizedTime = L10N.numberWithDecimals(normalizedTime, REQUEST_TIME_DECIMALS); } let node = document.createElement("label"); let text = L10N.getFormatStr("networkMenu." + divisionScale, normalizedTime); node.className = "plain requests-menu-timings-division"; node.setAttribute("division-scale", divisionScale); node.style.transform = translateX; node.setAttribute("value", text); fragment.appendChild(node); } container.appendChild(fragment); container.className = "requests-menu-waterfall-visible"; } }, /** * Creates the background displayed on each waterfall view in this container. * * @param number scale * The current waterfall scale. */ _drawWaterfallBackground: function (scale) { 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; // Awww yeah, 1px, repeats on Y axis. let canvasHeight = canvas.height = 1; // Start over. let imageData = ctx.createImageData(canvasWidth, canvasHeight); let pixelArray = imageData.data; let buf = new ArrayBuffer(pixelArray.length); let view8bit = new Uint8ClampedArray(buf); let view32bit = 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 = scale * 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; view32bit[position] = (alphaComponent << 24) | (b << 16) | (g << 8) | r; } alphaComponent += REQUESTS_WATERFALL_BACKGROUND_TICKS_OPACITY_ADD; } } { let t = NetMonitorController.NetworkEventsHandler .firstDocumentDOMContentLoadedTimestamp; let delta = Math.floor((t - this._firstRequestStartedMillis) * scale); let [r1, g1, b1, a1] = REQUESTS_WATERFALL_DOMCONTENTLOADED_TICKS_COLOR_RGBA; view32bit[delta] = (a1 << 24) | (r1 << 16) | (g1 << 8) | b1; } { let t = NetMonitorController.NetworkEventsHandler .firstDocumentLoadTimestamp; let delta = Math.floor((t - this._firstRequestStartedMillis) * scale); let [r2, g2, b2, a2] = REQUESTS_WATERFALL_LOAD_TICKS_COLOR_RGBA; view32bit[delta] = (a2 << 24) | (r2 << 16) | (g2 << 8) | b2; } // Flush the image data and cache the waterfall background. pixelArray.set(view8bit); ctx.putImageData(imageData, 0, 0); document.mozSetImageElement("waterfall-background", canvas); }, /** * The selection listener for this container. */ _onSelect: function ({ detail: item }) { if (item) { NetMonitorView.Sidebar.populate(item.attachment); NetMonitorView.Sidebar.toggle(true); } else { NetMonitorView.Sidebar.toggle(false); } }, /** * The swap listener for this container. * Called when two items switch places, when the contents are sorted. */ _onSwap: function ({ detail: [firstItem, secondItem] }) { // Reattach click listener to the security icons this.attachSecurityIconClickListener(firstItem); this.attachSecurityIconClickListener(secondItem); }, /** * The predicate used when deciding whether a popup should be shown * over a request item or not. * * @param nsIDOMNode target * The element node currently being hovered. * @param object tooltip * The current tooltip instance. * @return {Promise} */ _onHover: Task.async(function* (target, tooltip) { let requestItem = this.getItemForElement(target); if (!requestItem) { return false; } let hovered = requestItem.attachment; if (hovered.responseContent && target.closest(".requests-menu-icon-and-file")) { return this._setTooltipImageContent(tooltip, requestItem); } else if (hovered.cause && target.closest(".requests-menu-cause-stack")) { return this._setTooltipStackTraceContent(tooltip, requestItem); } return false; }), _setTooltipImageContent: Task.async(function* (tooltip, requestItem) { let { mimeType, text, encoding } = requestItem.attachment.responseContent.content; if (!mimeType || !mimeType.includes("image/")) { return false; } let string = yield gNetwork.getString(text); let src = formDataURI(mimeType, encoding, string); let maxDim = REQUESTS_TOOLTIP_IMAGE_MAX_DIM; let { naturalWidth, naturalHeight } = yield getImageDimensions(tooltip.doc, src); let options = { maxDim, naturalWidth, naturalHeight }; setImageTooltip(tooltip, tooltip.doc, src, options); return $(".requests-menu-icon", requestItem.target); }), _setTooltipStackTraceContent: Task.async(function* (tooltip, requestItem) { let {stacktrace} = requestItem.attachment.cause; if (!stacktrace || stacktrace.length == 0) { return false; } let doc = tooltip.doc; let el = doc.createElementNS(HTML_NS, "div"); el.className = "stack-trace-tooltip devtools-monospace"; for (let f of stacktrace) { let { functionName, filename, lineNumber, columnNumber, asyncCause } = f; if (asyncCause) { // if there is asyncCause, append a "divider" row into the trace let asyncFrameEl = doc.createElementNS(HTML_NS, "div"); asyncFrameEl.className = "stack-frame stack-frame-async"; asyncFrameEl.textContent = WEBCONSOLE_L10N.getFormatStr("stacktrace.asyncStack", asyncCause); el.appendChild(asyncFrameEl); } // Parse a source name in format "url -> url" let sourceUrl = filename.split(" -> ").pop(); let frameEl = doc.createElementNS(HTML_NS, "div"); frameEl.className = "stack-frame stack-frame-call"; let funcEl = doc.createElementNS(HTML_NS, "span"); funcEl.className = "stack-frame-function-name"; funcEl.textContent = functionName || WEBCONSOLE_L10N.getStr("stacktrace.anonymousFunction"); frameEl.appendChild(funcEl); let sourceEl = doc.createElementNS(HTML_NS, "span"); sourceEl.className = "stack-frame-source-name"; frameEl.appendChild(sourceEl); let sourceInnerEl = doc.createElementNS(HTML_NS, "span"); sourceInnerEl.className = "stack-frame-source-name-inner"; sourceEl.appendChild(sourceInnerEl); sourceInnerEl.textContent = sourceUrl; sourceInnerEl.title = sourceUrl; let lineEl = doc.createElementNS(HTML_NS, "span"); lineEl.className = "stack-frame-line"; lineEl.textContent = `:${lineNumber}:${columnNumber}`; sourceInnerEl.appendChild(lineEl); frameEl.addEventListener("click", () => { // hide the tooltip immediately, not after delay tooltip.hide(); NetMonitorController.viewSourceInDebugger(filename, lineNumber); }, false); el.appendChild(frameEl); } tooltip.setContent(el, {width: REQUESTS_TOOLTIP_STACK_TRACE_WIDTH}); return true; }), /** * A handler that opens the security tab in the details view if secure or * broken security indicator is clicked. */ _onSecurityIconClick: function (e) { let state = this.selectedItem.attachment.securityState; if (state !== "insecure") { // Choose the security tab. NetMonitorView.NetworkDetails.widget.selectedIndex = 5; } }, /** * The resize listener for this container's window. */ _onResize: function (e) { // Allow requests to settle down first. setNamedTimeout("resize-events", RESIZE_REFRESH_RATE, () => this._flushWaterfallViews(true)); }, /** * Scroll listener for the requests menu view. */ _onScroll: function () { this.tooltip.hide(); }, /** * Open context menu */ _onContextMenu: function (e) { e.preventDefault(); this.contextMenu.open(e); }, /** * Checks if the specified unix time is the first one to be known of, * and saves it if so. * * @param number unixTime * The milliseconds to check and save. */ _registerFirstRequestStart: function (unixTime) { if (this._firstRequestStartedMillis == -1) { this._firstRequestStartedMillis = unixTime; } }, /** * Checks if the specified unix time is the last one to be known of, * and saves it if so. * * @param number unixTime * The milliseconds to check and save. */ _registerLastRequestEnd: function (unixTime) { if (this._lastRequestEndedMillis < unixTime) { this._lastRequestEndedMillis = unixTime; } }, /** * 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; }, _splitter: null, _summary: null, _canvas: null, _ctx: null, _cachedWaterfallWidth: 0, _firstRequestStartedMillis: -1, _lastRequestEndedMillis: -1, _updateQueue: [], _addQueue: [], _updateTimeout: null, _resizeTimeout: null, _activeFilters: ["all"], _currentFreetextFilter: "" }); exports.RequestsMenuView = RequestsMenuView;