Mypal/devtools/client/netmonitor/requests-menu-view.js

1589 lines
54 KiB
JavaScript

/* 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;
this.resetNotPersistent();
},
/**
* Reset informations that "devtools.webconsole.persistlog == true".
*/
resetNotPersistent: function () {
this._firstRequestStartedMillisNotPersistent = -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;
// display Status-Line above other response headers
let selectedStatusLine = selected.httpVersion
+ " " + selected.status
+ " " + selected.statusText
+ "\n";
requestTextarea.value = writeHeaderText(selectedRequestHeaders);
// sometimes it's empty
if (selected.responseHeaders) {
let selectedResponseHeaders = selected.responseHeaders.headers;
responseTextare.value = selectedStatusLine
+ writeHeaderText(selectedResponseHeaders);
} else {
responseTextare.value = selectedStatusLine;
}
$("#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: {
firstRequestStartedMillisNotPersistent: this._firstRequestStartedMillisNotPersistent,
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;
}
if (this._firstRequestStartedMillisNotPersistent == -1) {
this._firstRequestStartedMillisNotPersistent = 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,
_firstRequestStartedMillisNotPersistent: -1,
_lastRequestEndedMillis: -1,
_updateQueue: [],
_addQueue: [],
_updateTimeout: null,
_resizeTimeout: null,
_activeFilters: ["all"],
_currentFreetextFilter: ""
});
exports.RequestsMenuView = RequestsMenuView;