summaryrefslogtreecommitdiffstats
path: root/devtools/client/netmonitor/src/components/request-details
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /devtools/client/netmonitor/src/components/request-details
parentInitial commit. (diff)
downloadthunderbird-upstream.tar.xz
thunderbird-upstream.zip
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/netmonitor/src/components/request-details')
-rw-r--r--devtools/client/netmonitor/src/components/request-details/CachePanel.js144
-rw-r--r--devtools/client/netmonitor/src/components/request-details/CookiesPanel.js225
-rw-r--r--devtools/client/netmonitor/src/components/request-details/HeadersPanel.js850
-rw-r--r--devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js106
-rw-r--r--devtools/client/netmonitor/src/components/request-details/PropertiesView.js247
-rw-r--r--devtools/client/netmonitor/src/components/request-details/RequestPanel.js301
-rw-r--r--devtools/client/netmonitor/src/components/request-details/ResponsePanel.js496
-rw-r--r--devtools/client/netmonitor/src/components/request-details/SecurityPanel.js283
-rw-r--r--devtools/client/netmonitor/src/components/request-details/StackTracePanel.js81
-rw-r--r--devtools/client/netmonitor/src/components/request-details/TimingsPanel.js229
-rw-r--r--devtools/client/netmonitor/src/components/request-details/moz.build16
11 files changed, 2978 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/src/components/request-details/CachePanel.js b/devtools/client/netmonitor/src/components/request-details/CachePanel.js
new file mode 100644
index 0000000000..5f3db6c501
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/CachePanel.js
@@ -0,0 +1,144 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ fetchNetworkUpdatePacket,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+
+// Components
+const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js");
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+
+const { div, input } = dom;
+
+const CACHE = L10N.getStr("netmonitor.cache.cache");
+const DATA_SIZE = L10N.getStr("netmonitor.cache.dataSize");
+const EXPIRES = L10N.getStr("netmonitor.cache.expires");
+const FETCH_COUNT = L10N.getStr("netmonitor.cache.fetchCount");
+const LAST_FETCHED = L10N.getStr("netmonitor.cache.lastFetched");
+const LAST_MODIFIED = L10N.getStr("netmonitor.cache.lastModified");
+const DEVICE = L10N.getStr("netmonitor.cache.device");
+const NOT_AVAILABLE = L10N.getStr("netmonitor.cache.notAvailable");
+const EMPTY = L10N.getStr("netmonitor.cache.empty");
+
+/**
+ * Cache panel component
+ * This tab lists full details of any cache information of the response.
+ */
+class CachePanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ openLink: PropTypes.func,
+ request: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ const { connector, request } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["responseCache"]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { connector, request } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["responseCache"]);
+ }
+
+ renderSummary(label, value) {
+ return div(
+ { className: "tabpanel-summary-container cache-summary" },
+ div(
+ {
+ className: "tabpanel-summary-label cache-summary-label",
+ },
+ label + ":"
+ ),
+ input({
+ className: "tabpanel-summary-value textbox-input devtools-monospace",
+ readOnly: true,
+ value,
+ })
+ );
+ }
+
+ getProperties(responseCache) {
+ let responseCacheObj;
+ let cacheObj;
+ try {
+ responseCacheObj = Object.assign({}, responseCache);
+ responseCacheObj = responseCacheObj.cache;
+ } catch (e) {
+ return null;
+ }
+ try {
+ cacheObj = Object.assign({}, responseCacheObj);
+ } catch (e) {
+ return null;
+ }
+ return cacheObj;
+ }
+
+ getDate(timestamp) {
+ if (!timestamp) {
+ return null;
+ }
+ const d = new Date(parseInt(timestamp, 10) * 1000);
+ return d.toLocaleDateString() + " " + d.toLocaleTimeString();
+ }
+
+ render() {
+ const { request } = this.props;
+ const { responseCache } = request;
+
+ let object;
+ const cache = this.getProperties(responseCache);
+
+ if (
+ cache.lastFetched ||
+ cache.fetchCount ||
+ cache.storageDataSize ||
+ cache.lastModified ||
+ cache.expirationTime ||
+ cache.deviceID
+ ) {
+ object = {
+ [CACHE]: {
+ [LAST_FETCHED]: this.getDate(cache.lastFetched) || NOT_AVAILABLE,
+ [FETCH_COUNT]: cache.fetchCount || NOT_AVAILABLE,
+ [DATA_SIZE]: cache.storageDataSize || NOT_AVAILABLE,
+ [LAST_MODIFIED]: this.getDate(cache.lastModified) || NOT_AVAILABLE,
+ [EXPIRES]: this.getDate(cache.expirationTime) || NOT_AVAILABLE,
+ [DEVICE]: cache.deviceID || NOT_AVAILABLE,
+ },
+ };
+ } else {
+ return div({ className: "empty-notice" }, EMPTY);
+ }
+
+ return div(
+ { className: "panel-container security-panel" },
+ PropertiesView({
+ object,
+ enableFilter: false,
+ expandedNodes: TreeViewClass.getExpandedNodes(object),
+ })
+ );
+ }
+}
+
+module.exports = CachePanel;
diff --git a/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js b/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js
new file mode 100644
index 0000000000..466c0379cd
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/CookiesPanel.js
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ fetchNetworkUpdatePacket,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ sortObjectKeys,
+} = require("resource://devtools/client/netmonitor/src/utils/sort-utils.js");
+const {
+ FILTER_SEARCH_DELAY,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+
+// Component
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+const SearchBox = createFactory(
+ require("resource://devtools/client/shared/components/SearchBox.js")
+);
+const Accordion = createFactory(
+ require("resource://devtools/client/shared/components/Accordion.js")
+);
+
+loader.lazyGetter(this, "TreeRow", function () {
+ return createFactory(
+ require("resource://devtools/client/shared/components/tree/TreeRow.js")
+ );
+});
+
+const { div } = dom;
+
+const COOKIES_EMPTY_TEXT = L10N.getStr("cookiesEmptyText");
+const COOKIES_FILTER_TEXT = L10N.getStr("cookiesFilterText");
+const REQUEST_COOKIES = L10N.getStr("requestCookies");
+const RESPONSE_COOKIES = L10N.getStr("responseCookies");
+
+/*
+ * Cookies panel component
+ * This tab lists full details of any cookies sent with the request or response
+ */
+class CookiesPanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ openLink: PropTypes.func,
+ request: PropTypes.object.isRequired,
+ targetSearchResult: PropTypes.object,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filterText: "",
+ };
+ }
+
+ componentDidMount() {
+ const { connector, request } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestCookies",
+ "responseCookies",
+ ]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { connector, request } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestCookies",
+ "responseCookies",
+ ]);
+ }
+
+ /**
+ * Mapping array to dict for TreeView usage.
+ * Since TreeView only support Object(dict) format.
+ *
+ * @param {Object[]} arr - key-value pair array like cookies or params
+ * @returns {Object}
+ */
+ getProperties(arr, title) {
+ const cookies = arr.reduce((map, obj) => {
+ // Generally cookies object contains only name and value properties and can
+ // be rendered as name: value pair.
+ // When there are more properties in cookies object such as extra or path,
+ // We will pass the object to display these extra information
+ if (Object.keys(obj).length > 2) {
+ map[obj.name] = Object.assign({}, obj);
+ delete map[obj.name].name;
+ } else {
+ map[obj.name] = obj.value;
+ }
+ return map;
+ }, Object.create(null));
+
+ // To have different roots for Request and Response cookies
+ return { [title]: cookies };
+ }
+
+ /**
+ * Custom rendering method passed to PropertiesView. It's
+ * responsible to filter out level 0 node in the tree
+ *
+ * @param {Object} props
+ */
+ renderRow(props) {
+ const { level } = props.member;
+
+ if (level === 0) {
+ return null;
+ }
+
+ return TreeRow(props);
+ }
+
+ /**
+ * Get the selected cookies path
+ * @param {Object} searchResult
+ * @returns {string}
+ */
+ getTargetCookiePath(searchResult) {
+ if (!searchResult) {
+ return null;
+ }
+
+ switch (searchResult.type) {
+ case "requestCookies": {
+ return `/${REQUEST_COOKIES}/${searchResult.label}`;
+ }
+ case "responseCookies":
+ return `/${RESPONSE_COOKIES}/${searchResult.label}`;
+ }
+
+ return null;
+ }
+
+ render() {
+ let {
+ request: {
+ requestCookies = { cookies: [] },
+ responseCookies = { cookies: [] },
+ },
+ targetSearchResult,
+ } = this.props;
+
+ const { filterText } = this.state;
+
+ requestCookies = requestCookies.cookies || requestCookies;
+ responseCookies = responseCookies.cookies || responseCookies;
+
+ if (!requestCookies.length && !responseCookies.length) {
+ return div({ className: "empty-notice" }, COOKIES_EMPTY_TEXT);
+ }
+
+ const items = [];
+
+ if (responseCookies.length) {
+ items.push({
+ component: PropertiesView,
+ componentProps: {
+ object: sortObjectKeys(
+ this.getProperties(responseCookies, RESPONSE_COOKIES)
+ ),
+ filterText,
+ targetSearchResult,
+ defaultSelectFirstNode: false,
+ selectPath: this.getTargetCookiePath,
+ renderRow: this.renderRow,
+ },
+ header: RESPONSE_COOKIES,
+ id: "responseCookies",
+ opened: true,
+ });
+ }
+
+ if (requestCookies.length) {
+ items.push({
+ component: PropertiesView,
+ componentProps: {
+ object: sortObjectKeys(
+ this.getProperties(requestCookies, REQUEST_COOKIES)
+ ),
+ filterText,
+ targetSearchResult,
+ defaultSelectFirstNode: false,
+ selectPath: this.getTargetCookiePath,
+ renderRow: this.renderRow,
+ },
+ header: REQUEST_COOKIES,
+ id: "requestCookies",
+ opened: true,
+ });
+ }
+
+ return div(
+ { className: "panel-container cookies-panel-container" },
+ div(
+ { className: "devtools-toolbar devtools-input-toolbar" },
+ SearchBox({
+ delay: FILTER_SEARCH_DELAY,
+ type: "filter",
+ onChange: text => this.setState({ filterText: text }),
+ placeholder: COOKIES_FILTER_TEXT,
+ })
+ ),
+ Accordion({ items })
+ );
+ }
+}
+
+module.exports = CookiesPanel;
diff --git a/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js b/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js
new file mode 100644
index 0000000000..80b9aed27d
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/HeadersPanel.js
@@ -0,0 +1,850 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
+const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ getFormattedIPAndPort,
+ getFormattedSize,
+ getRequestPriorityAsText,
+} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ getHeadersURL,
+ getTrackingProtectionURL,
+ getHTTPStatusCodeURL,
+} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
+const {
+ fetchNetworkUpdatePacket,
+ writeHeaderText,
+ getRequestHeadersRawText,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ HeadersProvider,
+ HeaderList,
+} = require("resource://devtools/client/netmonitor/src/utils/headers-provider.js");
+const {
+ FILTER_SEARCH_DELAY,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+// Components
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+const SearchBox = createFactory(
+ require("resource://devtools/client/shared/components/SearchBox.js")
+);
+const Accordion = createFactory(
+ require("resource://devtools/client/shared/components/Accordion.js")
+);
+const UrlPreview = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/UrlPreview.js")
+);
+const HeadersPanelContextMenu = require("resource://devtools/client/netmonitor/src/widgets/HeadersPanelContextMenu.js");
+const StatusCode = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/StatusCode.js")
+);
+
+loader.lazyGetter(this, "MDNLink", function () {
+ return createFactory(
+ require("resource://devtools/client/shared/components/MdnLink.js")
+ );
+});
+loader.lazyGetter(this, "Rep", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .REPS.Rep;
+});
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+loader.lazyGetter(this, "TreeRow", function () {
+ return createFactory(
+ require("resource://devtools/client/shared/components/tree/TreeRow.js")
+ );
+});
+loader.lazyRequireGetter(
+ this,
+ "showMenu",
+ "resource://devtools/client/shared/components/menu/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "openContentLink",
+ "resource://devtools/client/shared/link.js",
+ true
+);
+
+const { div, input, label, span, textarea, tr, td, button } = dom;
+
+const RESEND = L10N.getStr("netmonitor.context.resend.label");
+const EDIT_AND_RESEND = L10N.getStr("netmonitor.summary.editAndResend");
+const RAW_HEADERS = L10N.getStr("netmonitor.headers.raw");
+const HEADERS_EMPTY_TEXT = L10N.getStr("headersEmptyText");
+const HEADERS_FILTER_TEXT = L10N.getStr("headersFilterText");
+const REQUEST_HEADERS = L10N.getStr("requestHeaders");
+const REQUEST_HEADERS_FROM_UPLOAD = L10N.getStr("requestHeadersFromUpload");
+const RESPONSE_HEADERS = L10N.getStr("responseHeaders");
+const HEADERS_STATUS = L10N.getStr("netmonitor.headers.status");
+const HEADERS_VERSION = L10N.getStr("netmonitor.headers.version");
+const HEADERS_TRANSFERRED = L10N.getStr("netmonitor.toolbar.transferred");
+const SUMMARY_STATUS_LEARN_MORE = L10N.getStr("netmonitor.summary.learnMore");
+const SUMMARY_ETP_LEARN_MORE = L10N.getStr(
+ "netmonitor.enhancedTrackingProtection.learnMore"
+);
+const HEADERS_REFERRER = L10N.getStr("netmonitor.headers.referrerPolicy");
+const HEADERS_CONTENT_BLOCKING = L10N.getStr(
+ "netmonitor.headers.contentBlocking"
+);
+const HEADERS_ETP = L10N.getStr(
+ "netmonitor.trackingResource.enhancedTrackingProtection"
+);
+const HEADERS_PRIORITY = L10N.getStr("netmonitor.headers.requestPriority");
+
+/**
+ * Headers panel component
+ * Lists basic information about the request
+ *
+ * In http/2 all response headers are in small case.
+ * See: https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_details/index.html#response-headers
+ * RFC: https://tools.ietf.org/html/rfc7540#section-8.1.2
+ */
+class HeadersPanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ cloneSelectedRequest: PropTypes.func.isRequired,
+ member: PropTypes.object,
+ request: PropTypes.object.isRequired,
+ renderValue: PropTypes.func,
+ openLink: PropTypes.func,
+ targetSearchResult: PropTypes.object,
+ openRequestBlockingAndAddUrl: PropTypes.func.isRequired,
+ openHTTPCustomRequestTab: PropTypes.func.isRequired,
+ cloneRequest: PropTypes.func,
+ sendCustomRequest: PropTypes.func,
+ shouldExpandPreview: PropTypes.bool,
+ setHeadersUrlPreviewExpanded: PropTypes.func,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ rawRequestHeadersOpened: false,
+ rawResponseHeadersOpened: false,
+ rawUploadHeadersOpened: false,
+ lastToggledRawHeader: "",
+ filterText: null,
+ };
+
+ this.getProperties = this.getProperties.bind(this);
+ this.getTargetHeaderPath = this.getTargetHeaderPath.bind(this);
+ this.toggleRawResponseHeaders = this.toggleRawResponseHeaders.bind(this);
+ this.toggleRawRequestHeaders = this.toggleRawRequestHeaders.bind(this);
+ this.toggleRawUploadHeaders = this.toggleRawUploadHeaders.bind(this);
+ this.renderSummary = this.renderSummary.bind(this);
+ this.renderRow = this.renderRow.bind(this);
+ this.renderValue = this.renderValue.bind(this);
+ this.renderRawHeadersBtn = this.renderRawHeadersBtn.bind(this);
+ this.onShowResendMenu = this.onShowResendMenu.bind(this);
+ this.onShowHeadersContextMenu = this.onShowHeadersContextMenu.bind(this);
+ }
+
+ componentDidMount() {
+ const { request, connector } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestHeaders",
+ "responseHeaders",
+ "requestPostData",
+ ]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { request, connector } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestHeaders",
+ "responseHeaders",
+ "requestPostData",
+ ]);
+ }
+
+ getHeadersTitle(headers, title) {
+ let result = "";
+ let preHeaderText = "";
+ const {
+ responseHeaders,
+ requestHeaders,
+ httpVersion,
+ status,
+ statusText,
+ method,
+ urlDetails,
+ } = this.props.request;
+ if (headers?.headers.length) {
+ if (!headers.headersSize) {
+ if (title == RESPONSE_HEADERS) {
+ preHeaderText = `${httpVersion} ${status} ${statusText}`;
+ result = `${title} (${getFormattedSize(
+ writeHeaderText(responseHeaders.headers, preHeaderText).length,
+ 3
+ )})`;
+ } else {
+ const hostHeader = requestHeaders.headers.find(
+ ele => ele.name === "Host"
+ );
+ if (hostHeader) {
+ preHeaderText = `${method} ${
+ urlDetails.url.split(hostHeader.value)[1]
+ } ${httpVersion}`;
+ }
+ result = `${title} (${getFormattedSize(
+ writeHeaderText(requestHeaders.headers, preHeaderText).length,
+ 3
+ )})`;
+ }
+ } else {
+ result = `${title} (${getFormattedSize(headers.headersSize, 3)})`;
+ }
+ }
+
+ return result;
+ }
+
+ getProperties(headers, title) {
+ let propertiesResult;
+
+ if (headers?.headers.length) {
+ const headerKey = this.getHeadersTitle(headers, title);
+ propertiesResult = {
+ [headerKey]: new HeaderList(headers.headers),
+ };
+ if (
+ (title === RESPONSE_HEADERS && this.state.rawResponseHeadersOpened) ||
+ (title === REQUEST_HEADERS && this.state.rawRequestHeadersOpened) ||
+ (title === REQUEST_HEADERS_FROM_UPLOAD &&
+ this.state.rawUploadHeadersOpened)
+ ) {
+ propertiesResult = {
+ [headerKey]: { RAW_HEADERS_ID: headers.rawHeaders },
+ };
+ }
+ }
+ return propertiesResult;
+ }
+
+ toggleRawResponseHeaders() {
+ this.setState({
+ rawResponseHeadersOpened: !this.state.rawResponseHeadersOpened,
+ lastToggledRawHeader: "response",
+ });
+ }
+
+ toggleRawRequestHeaders() {
+ this.setState({
+ rawRequestHeadersOpened: !this.state.rawRequestHeadersOpened,
+ lastToggledRawHeader: "request",
+ });
+ }
+
+ toggleRawUploadHeaders() {
+ this.setState({
+ rawUploadHeadersOpened: !this.state.rawUploadHeadersOpened,
+ lastToggledRawHeader: "upload",
+ });
+ }
+
+ /**
+ * Helper method to identify what kind of raw header this is.
+ * Information is in the path variable
+ */
+ getRawHeaderType(path) {
+ if (path.includes(RESPONSE_HEADERS)) {
+ return "RESPONSE";
+ }
+ if (path.includes(REQUEST_HEADERS_FROM_UPLOAD)) {
+ return "UPLOAD";
+ }
+ return "REQUEST";
+ }
+
+ /**
+ * Renders the top part of the headers detail panel - Summary.
+ */
+ renderSummary(summaryLabel, value) {
+ return div(
+ {
+ key: summaryLabel,
+ className: "tabpanel-summary-container headers-summary",
+ },
+ span(
+ { className: "tabpanel-summary-label headers-summary-label" },
+ summaryLabel
+ ),
+ span({ className: "tabpanel-summary-value" }, value)
+ );
+ }
+
+ /**
+ * Get path for target header if it's set. It's used to select
+ * the header automatically within the tree of headers.
+ * Note that the target header is set by the Search panel.
+ */
+ getTargetHeaderPath(searchResult) {
+ if (!searchResult) {
+ return null;
+ }
+ if (
+ searchResult.type !== "requestHeaders" &&
+ searchResult.type !== "responseHeaders" &&
+ searchResult.type !== "requestHeadersFromUploadStream"
+ ) {
+ return null;
+ }
+ const {
+ request: {
+ requestHeaders,
+ requestHeadersFromUploadStream: uploadHeaders,
+ responseHeaders,
+ },
+ } = this.props;
+ // Using `HeaderList` ensures that we'll get the same
+ // header index as it's used in the tree.
+ const getPath = (headers, title) => {
+ return (
+ "/" +
+ this.getHeadersTitle(headers, title) +
+ "/" +
+ new HeaderList(headers.headers).headers.findIndex(
+ header => header.name == searchResult.label
+ )
+ );
+ };
+ // Calculate target header path according to the header type.
+ switch (searchResult.type) {
+ case "requestHeaders":
+ return getPath(requestHeaders, REQUEST_HEADERS);
+ case "responseHeaders":
+ return getPath(responseHeaders, RESPONSE_HEADERS);
+ case "requestHeadersFromUploadStream":
+ return getPath(uploadHeaders, REQUEST_HEADERS_FROM_UPLOAD);
+ }
+ return null;
+ }
+
+ /**
+ * Custom rendering method passed to PropertiesView. It's responsible
+ * for rendering <textarea> element with raw headers data.
+ */
+ renderRow(props) {
+ const { level, path } = props.member;
+
+ const {
+ request: {
+ method,
+ httpVersion,
+ requestHeaders,
+ requestHeadersFromUploadStream: uploadHeaders,
+ responseHeaders,
+ status,
+ statusText,
+ urlDetails,
+ },
+ } = this.props;
+
+ let value;
+ let preHeaderText = "";
+ if (path.includes("RAW_HEADERS_ID")) {
+ const rawHeaderType = this.getRawHeaderType(path);
+ switch (rawHeaderType) {
+ case "REQUEST":
+ value = getRequestHeadersRawText(
+ method,
+ httpVersion,
+ requestHeaders,
+ urlDetails
+ );
+ break;
+ case "RESPONSE":
+ preHeaderText = `${httpVersion} ${status} ${statusText}`;
+ value = writeHeaderText(
+ responseHeaders.headers,
+ preHeaderText
+ ).trim();
+ break;
+ case "UPLOAD":
+ value = writeHeaderText(uploadHeaders.headers, preHeaderText).trim();
+ break;
+ }
+
+ let rows;
+ if (value) {
+ const match = value.match(/\n/g);
+ rows = match !== null ? match.length : 0;
+ // Need to add 1 for the horizontal scrollbar
+ // not to cover the last row of raw data
+ rows = rows + 1;
+ }
+
+ return tr(
+ {
+ key: path,
+ role: "treeitem",
+ className: "raw-headers-container",
+ onClick: event => {
+ event.stopPropagation();
+ },
+ },
+ td(
+ {
+ colSpan: 2,
+ },
+ textarea({
+ className: "raw-headers",
+ rows,
+ value,
+ readOnly: true,
+ })
+ )
+ );
+ }
+
+ if (level !== 1) {
+ return null;
+ }
+
+ return TreeRow(props);
+ }
+
+ renderRawHeadersBtn(key, checked, onChange) {
+ return [
+ label(
+ {
+ key: `${key}RawHeadersBtn`,
+ className: "raw-headers-toggle",
+ htmlFor: `raw-${key}-checkbox`,
+ onClick: event => {
+ // stop the header click event
+ event.stopPropagation();
+ },
+ },
+ span({ className: "headers-summary-label" }, RAW_HEADERS),
+ span(
+ { className: "raw-headers-toggle-input" },
+ input({
+ id: `raw-${key}-checkbox`,
+ checked,
+ className: "devtools-checkbox-toggle",
+ onChange,
+ type: "checkbox",
+ })
+ )
+ ),
+ ];
+ }
+
+ renderValue(props) {
+ const { member, value } = props;
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ const headerDocURL = getHeadersURL(member.name);
+
+ return div(
+ { className: "treeValueCellDivider" },
+ Rep(
+ Object.assign(props, {
+ // FIXME: A workaround for the issue in StringRep
+ // Force StringRep to crop the text everytime
+ member: Object.assign({}, member, { open: false }),
+ mode: MODE.TINY,
+ noGrip: true,
+ openLink: openContentLink,
+ })
+ ),
+ headerDocURL ? MDNLink({ url: headerDocURL }) : null
+ );
+ }
+
+ getShouldOpen(rawHeader, filterText, targetSearchResult) {
+ return (item, opened) => {
+ // If closed, open panel for these reasons
+ // 1.The raw header is switched on or off
+ // 2.The filter text is set
+ // 3.The search text is set
+ if (
+ (!opened && this.state.lastToggledRawHeader === rawHeader) ||
+ (!opened && filterText) ||
+ (!opened && targetSearchResult)
+ ) {
+ return true;
+ }
+ return !!opened;
+ };
+ }
+
+ onShowResendMenu(event) {
+ const {
+ request: { id },
+ cloneSelectedRequest,
+ cloneRequest,
+ sendCustomRequest,
+ } = this.props;
+ const menuItems = [
+ {
+ label: RESEND,
+ type: "button",
+ click: () => {
+ cloneRequest(id);
+ sendCustomRequest();
+ },
+ },
+ {
+ label: EDIT_AND_RESEND,
+ type: "button",
+ click: evt => {
+ cloneSelectedRequest(evt);
+ },
+ },
+ ];
+
+ showMenu(menuItems, { button: event.target });
+ }
+
+ onShowHeadersContextMenu(event) {
+ if (!this.contextMenu) {
+ this.contextMenu = new HeadersPanelContextMenu();
+ }
+ this.contextMenu.open(event, window.getSelection());
+ }
+
+ render() {
+ const {
+ targetSearchResult,
+ request: {
+ fromCache,
+ fromServiceWorker,
+ httpVersion,
+ method,
+ remoteAddress,
+ remotePort,
+ requestHeaders,
+ requestHeadersFromUploadStream: uploadHeaders,
+ responseHeaders,
+ status,
+ statusText,
+ urlDetails,
+ referrerPolicy,
+ priority,
+ isThirdPartyTrackingResource,
+ contentSize,
+ transferredSize,
+ },
+ openRequestBlockingAndAddUrl,
+ openHTTPCustomRequestTab,
+ shouldExpandPreview,
+ setHeadersUrlPreviewExpanded,
+ } = this.props;
+ const {
+ rawResponseHeadersOpened,
+ rawRequestHeadersOpened,
+ rawUploadHeadersOpened,
+ filterText,
+ } = this.state;
+
+ if (
+ (!requestHeaders || !requestHeaders.headers.length) &&
+ (!uploadHeaders || !uploadHeaders.headers.length) &&
+ (!responseHeaders || !responseHeaders.headers.length)
+ ) {
+ return div({ className: "empty-notice" }, HEADERS_EMPTY_TEXT);
+ }
+
+ const items = [];
+
+ if (responseHeaders?.headers.length) {
+ items.push({
+ component: PropertiesView,
+ componentProps: {
+ object: this.getProperties(responseHeaders, RESPONSE_HEADERS),
+ filterText,
+ targetSearchResult,
+ renderRow: this.renderRow,
+ renderValue: this.renderValue,
+ provider: HeadersProvider,
+ selectPath: this.getTargetHeaderPath,
+ defaultSelectFirstNode: false,
+ enableInput: false,
+ useQuotes: false,
+ },
+ header: this.getHeadersTitle(responseHeaders, RESPONSE_HEADERS),
+ buttons: this.renderRawHeadersBtn(
+ "response",
+ rawResponseHeadersOpened,
+ this.toggleRawResponseHeaders
+ ),
+ id: "responseHeaders",
+ opened: true,
+ shouldOpen: this.getShouldOpen(
+ "response",
+ filterText,
+ targetSearchResult
+ ),
+ });
+ }
+
+ if (requestHeaders?.headers.length) {
+ items.push({
+ component: PropertiesView,
+ componentProps: {
+ object: this.getProperties(requestHeaders, REQUEST_HEADERS),
+ filterText,
+ targetSearchResult,
+ renderRow: this.renderRow,
+ renderValue: this.renderValue,
+ provider: HeadersProvider,
+ selectPath: this.getTargetHeaderPath,
+ defaultSelectFirstNode: false,
+ enableInput: false,
+ useQuotes: false,
+ },
+ header: this.getHeadersTitle(requestHeaders, REQUEST_HEADERS),
+ buttons: this.renderRawHeadersBtn(
+ "request",
+ rawRequestHeadersOpened,
+ this.toggleRawRequestHeaders
+ ),
+ id: "requestHeaders",
+ opened: true,
+ shouldOpen: this.getShouldOpen(
+ "request",
+ filterText,
+ targetSearchResult
+ ),
+ });
+ }
+
+ if (uploadHeaders?.headers.length) {
+ items.push({
+ component: PropertiesView,
+ componentProps: {
+ object: this.getProperties(
+ uploadHeaders,
+ REQUEST_HEADERS_FROM_UPLOAD
+ ),
+ filterText,
+ targetSearchResult,
+ renderRow: this.renderRow,
+ renderValue: this.renderValue,
+ provider: HeadersProvider,
+ selectPath: this.getTargetHeaderPath,
+ defaultSelectFirstNode: false,
+ enableInput: false,
+ useQuotes: false,
+ },
+ header: this.getHeadersTitle(
+ uploadHeaders,
+ REQUEST_HEADERS_FROM_UPLOAD
+ ),
+ buttons: this.renderRawHeadersBtn(
+ "upload",
+ rawUploadHeadersOpened,
+ this.toggleRawUploadHeaders
+ ),
+ id: "uploadHeaders",
+ opened: true,
+ shouldOpen: this.getShouldOpen(
+ "upload",
+ filterText,
+ targetSearchResult
+ ),
+ });
+ }
+
+ const sizeText = L10N.getFormatStrWithNumbers(
+ "netmonitor.headers.sizeDetails",
+ getFormattedSize(transferredSize),
+ getFormattedSize(contentSize)
+ );
+
+ const summarySize = this.renderSummary(HEADERS_TRANSFERRED, sizeText);
+
+ let summaryStatus;
+ if (status) {
+ summaryStatus = div(
+ {
+ key: "headers-summary",
+ className: "tabpanel-summary-container headers-summary",
+ },
+ span(
+ {
+ className: "tabpanel-summary-label headers-summary-label",
+ },
+ HEADERS_STATUS
+ ),
+ span(
+ {
+ className: "tabpanel-summary-value status",
+ "data-code": status,
+ },
+ StatusCode({
+ item: { fromCache, fromServiceWorker, status, statusText },
+ }),
+ statusText,
+ MDNLink({
+ url: getHTTPStatusCodeURL(status),
+ title: SUMMARY_STATUS_LEARN_MORE,
+ })
+ )
+ );
+ }
+
+ let trackingProtectionStatus;
+ let trackingProtectionDetails = "";
+ if (isThirdPartyTrackingResource) {
+ const trackingProtectionDocURL = getTrackingProtectionURL();
+
+ trackingProtectionStatus = this.renderSummary(
+ HEADERS_CONTENT_BLOCKING,
+ div(null, span({ className: "tracking-resource" }), HEADERS_ETP)
+ );
+ trackingProtectionDetails = this.renderSummary(
+ "",
+ div(
+ {
+ key: "tracking-protection",
+ className: "tracking-protection",
+ },
+ L10N.getStr("netmonitor.trackingResource.tooltip"),
+ trackingProtectionDocURL
+ ? MDNLink({
+ url: trackingProtectionDocURL,
+ title: SUMMARY_ETP_LEARN_MORE,
+ })
+ : span({ className: "headers-summary learn-more-link" })
+ )
+ );
+ }
+
+ const summaryVersion = httpVersion
+ ? this.renderSummary(HEADERS_VERSION, httpVersion)
+ : null;
+
+ const summaryReferrerPolicy = referrerPolicy
+ ? this.renderSummary(HEADERS_REFERRER, referrerPolicy)
+ : null;
+
+ const summaryPriority = priority
+ ? this.renderSummary(HEADERS_PRIORITY, getRequestPriorityAsText(priority))
+ : null;
+
+ const summaryItems = [
+ summaryStatus,
+ summaryVersion,
+ summarySize,
+ summaryReferrerPolicy,
+ summaryPriority,
+ trackingProtectionStatus,
+ trackingProtectionDetails,
+ ].filter(summaryItem => summaryItem !== null);
+
+ const newEditAndResendPref = Services.prefs.getBoolPref(
+ "devtools.netmonitor.features.newEditAndResend"
+ );
+
+ return div(
+ { className: "headers-panel-container" },
+ div(
+ { className: "devtools-toolbar devtools-input-toolbar" },
+ SearchBox({
+ delay: FILTER_SEARCH_DELAY,
+ type: "filter",
+ onChange: text => this.setState({ filterText: text }),
+ placeholder: HEADERS_FILTER_TEXT,
+ }),
+ span({ className: "devtools-separator" }),
+ button(
+ {
+ id: "block-button",
+ className: "devtools-button",
+ title: L10N.getStr("netmonitor.context.blockURL"),
+ onClick: () => openRequestBlockingAndAddUrl(urlDetails.url),
+ },
+ L10N.getStr("netmonitor.headers.toolbar.block")
+ ),
+ span({ className: "devtools-separator" }),
+ button(
+ {
+ id: "edit-resend-button",
+ className: !newEditAndResendPref
+ ? "devtools-button devtools-dropdown-button"
+ : "devtools-button",
+ title: RESEND,
+ onClick: !newEditAndResendPref
+ ? this.onShowResendMenu
+ : () => {
+ openHTTPCustomRequestTab();
+ },
+ },
+ span({ className: "title" }, RESEND)
+ )
+ ),
+ div(
+ { className: "panel-container" },
+ div(
+ { className: "headers-overview" },
+ UrlPreview({
+ url: urlDetails.url,
+ method,
+ address: remoteAddress
+ ? getFormattedIPAndPort(remoteAddress, remotePort)
+ : null,
+ shouldExpandPreview,
+ onTogglePreview: expanded => setHeadersUrlPreviewExpanded(expanded),
+ }),
+ div(
+ {
+ className: "summary",
+ onContextMenu: this.onShowHeadersContextMenu,
+ },
+ summaryItems
+ )
+ ),
+ Accordion({ items })
+ )
+ );
+ }
+}
+
+module.exports = connect(
+ state => ({
+ shouldExpandPreview: state.ui.shouldExpandHeadersUrlPreview,
+ }),
+ (dispatch, props) => ({
+ setHeadersUrlPreviewExpanded: expanded =>
+ dispatch(Actions.setHeadersUrlPreviewExpanded(expanded)),
+ openRequestBlockingAndAddUrl: url =>
+ dispatch(Actions.openRequestBlockingAndAddUrl(url)),
+ openHTTPCustomRequestTab: () =>
+ dispatch(Actions.openHTTPCustomRequest(true)),
+ cloneRequest: id => dispatch(Actions.cloneRequest(id)),
+ sendCustomRequest: () => dispatch(Actions.sendCustomRequest()),
+ })
+)(HeadersPanel);
diff --git a/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js b/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js
new file mode 100644
index 0000000000..8092eedc87
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/NetworkDetailsBar.js
@@ -0,0 +1,106 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
+const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
+const {
+ getSelectedRequest,
+} = require("resource://devtools/client/netmonitor/src/selectors/index.js");
+
+// Components
+loader.lazyGetter(this, "CustomRequestPanel", function () {
+ return createFactory(
+ require("resource://devtools/client/netmonitor/src/components/CustomRequestPanel.js")
+ );
+});
+loader.lazyGetter(this, "TabboxPanel", function () {
+ return createFactory(
+ require("resource://devtools/client/netmonitor/src/components/TabboxPanel.js")
+ );
+});
+
+const { div } = dom;
+
+/**
+ * Network details panel component
+ */
+function NetworkDetailsBar({
+ connector,
+ activeTabId,
+ cloneSelectedRequest,
+ request,
+ selectTab,
+ sourceMapURLService,
+ toggleNetworkDetails,
+ openNetworkDetails,
+ openLink,
+ targetSearchResult,
+}) {
+ if (!request) {
+ return null;
+ }
+
+ const newEditAndResendPref = Services.prefs.getBoolPref(
+ "devtools.netmonitor.features.newEditAndResend"
+ );
+
+ return div(
+ { className: "network-details-bar" },
+ request.isCustom && !newEditAndResendPref
+ ? CustomRequestPanel({
+ connector,
+ request,
+ })
+ : TabboxPanel({
+ activeTabId,
+ cloneSelectedRequest,
+ connector,
+ openLink,
+ request,
+ selectTab,
+ sourceMapURLService,
+ toggleNetworkDetails,
+ openNetworkDetails,
+ targetSearchResult,
+ })
+ );
+}
+
+NetworkDetailsBar.displayName = "NetworkDetailsBar";
+
+NetworkDetailsBar.propTypes = {
+ connector: PropTypes.object.isRequired,
+ activeTabId: PropTypes.string,
+ cloneSelectedRequest: PropTypes.func.isRequired,
+ open: PropTypes.bool,
+ request: PropTypes.object,
+ selectTab: PropTypes.func.isRequired,
+ sourceMapURLService: PropTypes.object,
+ toggleNetworkDetails: PropTypes.func.isRequired,
+ openLink: PropTypes.func,
+ targetSearchResult: PropTypes.object,
+};
+
+module.exports = connect(
+ state => ({
+ activeTabId: state.ui.detailsPanelSelectedTab,
+ request: getSelectedRequest(state),
+ targetSearchResult: state.search.targetSearchResult,
+ }),
+ dispatch => ({
+ cloneSelectedRequest: () => dispatch(Actions.cloneSelectedRequest()),
+ selectTab: tabId => dispatch(Actions.selectDetailsPanelTab(tabId)),
+ toggleNetworkDetails: () => dispatch(Actions.toggleNetworkDetails()),
+ openNetworkDetails: open => dispatch(Actions.openNetworkDetails(open)),
+ })
+)(NetworkDetailsBar);
diff --git a/devtools/client/netmonitor/src/components/request-details/PropertiesView.js b/devtools/client/netmonitor/src/components/request-details/PropertiesView.js
new file mode 100644
index 0000000000..759653c1aa
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/PropertiesView.js
@@ -0,0 +1,247 @@
+/* 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/. */
+
+/* eslint-disable react/prop-types */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
+const {
+ setTargetSearchResult,
+} = require("resource://devtools/client/netmonitor/src/actions/search.js");
+
+// Components
+const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js");
+const TreeView = createFactory(TreeViewClass);
+const PropertiesViewContextMenu = require("resource://devtools/client/netmonitor/src/widgets/PropertiesViewContextMenu.js");
+
+loader.lazyGetter(this, "Rep", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .REPS.Rep;
+});
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+
+// Constants
+const {
+ AUTO_EXPAND_MAX_LEVEL,
+ AUTO_EXPAND_MAX_NODES,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+
+const { div } = dom;
+
+/**
+ * Properties View component
+ * A scrollable tree view component which provides some useful features for
+ * representing object properties.
+ *
+ * Tree view
+ * Rep
+ */
+class PropertiesView extends Component {
+ static get propTypes() {
+ return {
+ object: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
+ provider: PropTypes.object,
+ enableInput: PropTypes.bool,
+ expandableStrings: PropTypes.bool,
+ expandedNodes: PropTypes.object,
+ useBaseTreeViewExpand: PropTypes.bool,
+ filterText: PropTypes.string,
+ cropLimit: PropTypes.number,
+ targetSearchResult: PropTypes.object,
+ resetTargetSearchResult: PropTypes.func,
+ selectPath: PropTypes.func,
+ mode: PropTypes.symbol,
+ defaultSelectFirstNode: PropTypes.bool,
+ useQuotes: PropTypes.bool,
+ onClickRow: PropTypes.func,
+ contextMenuFormatters: PropTypes.object,
+ };
+ }
+
+ static get defaultProps() {
+ return {
+ enableInput: true,
+ enableFilter: true,
+ expandableStrings: false,
+ cropLimit: 1024,
+ useQuotes: true,
+ contextMenuFormatters: {},
+ useBaseTreeViewExpand: false,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.onFilter = this.onFilter.bind(this);
+ this.renderValueWithRep = this.renderValueWithRep.bind(this);
+ this.getSelectedPath = this.getSelectedPath.bind(this);
+ }
+
+ /**
+ * Update only if:
+ * 1) The rendered object has changed
+ * 2) The filter text has changed
+ * 3) The user selected another search result target.
+ */
+ shouldComponentUpdate(nextProps) {
+ return (
+ this.props.object !== nextProps.object ||
+ this.props.filterText !== nextProps.filterText ||
+ (this.props.targetSearchResult !== nextProps.targetSearchResult &&
+ nextProps.targetSearchResult !== null)
+ );
+ }
+
+ onFilter(props) {
+ const { name, value } = props;
+ const { filterText } = this.props;
+
+ if (!filterText) {
+ return true;
+ }
+
+ const jsonString = JSON.stringify({ [name]: value }).toLowerCase();
+ return jsonString.includes(filterText.toLowerCase());
+ }
+
+ getSelectedPath(targetSearchResult) {
+ if (!targetSearchResult) {
+ return null;
+ }
+
+ return `/${targetSearchResult.label}`;
+ }
+
+ /**
+ * If target is selected, let's scroll the content
+ * so the property is visible. This is used for search result navigation,
+ * which happens when the user clicks on a search result.
+ */
+ scrollSelectedIntoView() {
+ const { targetSearchResult, resetTargetSearchResult, selectPath } =
+ this.props;
+ if (!targetSearchResult) {
+ return;
+ }
+
+ const path =
+ typeof selectPath == "function"
+ ? selectPath(targetSearchResult)
+ : this.getSelectedPath(targetSearchResult);
+ const element = document.getElementById(path);
+ if (element) {
+ element.scrollIntoView({ block: "center" });
+ }
+
+ resetTargetSearchResult();
+ }
+
+ onContextMenuRow(member, evt) {
+ evt.preventDefault();
+
+ const { object } = member;
+
+ // Select the right clicked row
+ this.selectRow({ props: { member } });
+
+ // if data exists and can be copied, then show the contextmenu
+ if (typeof object === "object") {
+ if (!this.contextMenu) {
+ this.contextMenu = new PropertiesViewContextMenu({
+ customFormatters: this.props.contextMenuFormatters,
+ });
+ }
+ this.contextMenu.open(evt, window.getSelection(), {
+ member,
+ object: this.props.object,
+ });
+ }
+ }
+
+ renderValueWithRep(props) {
+ const { member } = props;
+
+ /* Hide strings with following conditions
+ * - the `value` object has a `value` property (only happens in Cookies panel)
+ */
+ if (typeof member.value === "object" && member.value?.value) {
+ return null;
+ }
+
+ return Rep(
+ Object.assign(props, {
+ // FIXME: A workaround for the issue in StringRep
+ // Force StringRep to crop the text every time
+ member: Object.assign({}, member, { open: false }),
+ mode: this.props.mode || MODE.TINY,
+ cropLimit: this.props.cropLimit,
+ noGrip: true,
+ })
+ );
+ }
+
+ render() {
+ const {
+ useBaseTreeViewExpand,
+ expandedNodes,
+ object,
+ renderValue,
+ targetSearchResult,
+ selectPath,
+ } = this.props;
+
+ let currentExpandedNodes;
+ // In the TreeView, when the component is re-rendered
+ // the state of `expandedNodes` is persisted by default
+ // e.g. when you open a node and filter the properties list,
+ // the node remains open.
+ // We have the prop `useBaseTreeViewExpand` to flag when we want to use
+ // this functionality or not.
+ if (!useBaseTreeViewExpand) {
+ currentExpandedNodes =
+ expandedNodes ||
+ TreeViewClass.getExpandedNodes(object, {
+ maxLevel: AUTO_EXPAND_MAX_LEVEL,
+ maxNodes: AUTO_EXPAND_MAX_NODES,
+ });
+ }
+ return div(
+ { className: "properties-view" },
+ div(
+ { className: "tree-container" },
+ TreeView({
+ ...this.props,
+ ref: () => this.scrollSelectedIntoView(),
+ columns: [{ id: "value", width: "100%" }],
+
+ expandedNodes: currentExpandedNodes,
+
+ onFilter: props => this.onFilter(props),
+ renderValue: renderValue || this.renderValueWithRep,
+ onContextMenuRow: this.onContextMenuRow,
+ selected:
+ typeof selectPath == "function"
+ ? selectPath(targetSearchResult)
+ : this.getSelectedPath(targetSearchResult),
+ })
+ )
+ );
+ }
+}
+
+module.exports = connect(null, dispatch => ({
+ resetTargetSearchResult: () => dispatch(setTargetSearchResult(null)),
+}))(PropertiesView);
diff --git a/devtools/client/netmonitor/src/components/request-details/RequestPanel.js b/devtools/client/netmonitor/src/components/request-details/RequestPanel.js
new file mode 100644
index 0000000000..5306161d2e
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/RequestPanel.js
@@ -0,0 +1,301 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ connect,
+} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ fetchNetworkUpdatePacket,
+ parseFormData,
+ parseJSON,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ sortObjectKeys,
+} = require("resource://devtools/client/netmonitor/src/utils/sort-utils.js");
+const {
+ FILTER_SEARCH_DELAY,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+const {
+ updateFormDataSections,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const Actions = require("resource://devtools/client/netmonitor/src/actions/index.js");
+
+// Components
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+const SearchBox = createFactory(
+ require("resource://devtools/client/shared/components/SearchBox.js")
+);
+
+loader.lazyGetter(this, "SourcePreview", function () {
+ return createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js")
+ );
+});
+
+const { div, input, label, span, h2 } = dom;
+
+const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName");
+const REQUEST_EMPTY_TEXT = L10N.getStr("paramsNoPayloadText");
+const REQUEST_FILTER_TEXT = L10N.getStr("paramsFilterText");
+const REQUEST_FORM_DATA = L10N.getStr("paramsFormData");
+const REQUEST_POST_PAYLOAD = L10N.getStr("paramsPostPayload");
+const RAW_REQUEST_PAYLOAD = L10N.getStr("netmonitor.request.raw");
+const REQUEST_TRUNCATED = L10N.getStr("requestTruncated");
+
+/**
+ * Params panel component
+ * Displays the GET parameters and POST data of a request
+ */
+class RequestPanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ openLink: PropTypes.func,
+ request: PropTypes.object.isRequired,
+ updateRequest: PropTypes.func.isRequired,
+ targetSearchResult: PropTypes.object,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ filterText: "",
+ rawRequestPayloadDisplayed: !!props.targetSearchResult,
+ };
+
+ this.toggleRawRequestPayload = this.toggleRawRequestPayload.bind(this);
+ this.renderRawRequestPayloadBtn =
+ this.renderRawRequestPayloadBtn.bind(this);
+ }
+
+ componentDidMount() {
+ const { request, connector } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestPostData",
+ ]);
+ updateFormDataSections(this.props);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { request, connector } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "requestPostData",
+ ]);
+ updateFormDataSections(nextProps);
+
+ if (nextProps.targetSearchResult !== null) {
+ this.setState({
+ rawRequestPayloadDisplayed: !!nextProps.targetSearchResult,
+ });
+ }
+ }
+
+ /**
+ * Update only if:
+ * 1) The rendered object has changed
+ * 2) The filter text has changed
+ * 2) The display got toggled between formatted and raw data
+ * 3) The user selected another search result target.
+ */
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.props.request !== nextProps.request ||
+ this.state.filterText !== nextState.filterText ||
+ this.state.rawRequestPayloadDisplayed !==
+ nextState.rawRequestPayloadDisplayed ||
+ this.props.targetSearchResult !== nextProps.targetSearchResult
+ );
+ }
+
+ /**
+ * Mapping array to dict for TreeView usage.
+ * Since TreeView only support Object(dict) format.
+ * This function also deal with duplicate key case
+ * (for multiple selection and query params with same keys)
+ *
+ * This function is not sorting result properties since it can
+ * results in unexpected order of params. See bug 1469533
+ *
+ * @param {Object[]} arr - key-value pair array or form params
+ * @returns {Object} Rep compatible object
+ */
+ getProperties(arr) {
+ return arr.reduce((map, obj) => {
+ const value = map[obj.name];
+ if (value || value === "") {
+ if (typeof value !== "object") {
+ map[obj.name] = [value];
+ }
+ map[obj.name].push(obj.value);
+ } else {
+ map[obj.name] = obj.value;
+ }
+ return map;
+ }, {});
+ }
+
+ toggleRawRequestPayload() {
+ this.setState({
+ rawRequestPayloadDisplayed: !this.state.rawRequestPayloadDisplayed,
+ });
+ }
+
+ renderRawRequestPayloadBtn(key, checked, onChange) {
+ return [
+ label(
+ {
+ key: `${key}RawRequestPayloadBtn`,
+ className: "raw-data-toggle",
+ htmlFor: `raw-${key}-checkbox`,
+ onClick: event => {
+ // stop the header click event
+ event.stopPropagation();
+ },
+ },
+ span({ className: "raw-data-toggle-label" }, RAW_REQUEST_PAYLOAD),
+ span(
+ { className: "raw-data-toggle-input" },
+ input({
+ id: `raw-${key}-checkbox`,
+ checked,
+ className: "devtools-checkbox-toggle",
+ onChange,
+ type: "checkbox",
+ })
+ )
+ ),
+ ];
+ }
+
+ renderRequestPayload(component, componentProps) {
+ return component(componentProps);
+ }
+
+ render() {
+ const { request, targetSearchResult } = this.props;
+ const { filterText, rawRequestPayloadDisplayed } = this.state;
+ const { formDataSections, mimeType, requestPostData } = request;
+ const postData = requestPostData ? requestPostData.postData?.text : null;
+
+ if ((!formDataSections || formDataSections.length === 0) && !postData) {
+ return div({ className: "empty-notice" }, REQUEST_EMPTY_TEXT);
+ }
+
+ let component;
+ let componentProps;
+ let requestPayloadLabel = REQUEST_POST_PAYLOAD;
+ let hasFormattedDisplay = false;
+
+ let error;
+
+ // Form Data section
+ if (formDataSections && formDataSections.length) {
+ const sections = formDataSections.filter(str => /\S/.test(str)).join("&");
+ component = PropertiesView;
+ componentProps = {
+ object: this.getProperties(parseFormData(sections)),
+ filterText,
+ targetSearchResult,
+ defaultSelectFirstNode: false,
+ };
+ requestPayloadLabel = REQUEST_FORM_DATA;
+ hasFormattedDisplay = true;
+ }
+
+ // Request payload section
+ const limit = Services.prefs.getIntPref(
+ "devtools.netmonitor.requestBodyLimit"
+ );
+
+ // Check if the request post data has been truncated from the backend,
+ // in which case no parse should be attempted.
+ if (postData && limit <= postData.length) {
+ error = REQUEST_TRUNCATED;
+ }
+ if (formDataSections && formDataSections.length === 0 && postData) {
+ if (!error) {
+ const jsonParsedPostData = parseJSON(postData);
+ const { json, strippedChars } = jsonParsedPostData;
+ // If XSSI characters were present in the request just display the raw
+ // data because a request should never have XSSI escape characters
+ if (strippedChars) {
+ hasFormattedDisplay = false;
+ } else if (json) {
+ component = PropertiesView;
+ componentProps = {
+ object: sortObjectKeys(json),
+ filterText,
+ targetSearchResult,
+ defaultSelectFirstNode: false,
+ };
+ requestPayloadLabel = JSON_SCOPE_NAME;
+ hasFormattedDisplay = true;
+ }
+ }
+ }
+
+ if (
+ (!hasFormattedDisplay || this.state.rawRequestPayloadDisplayed) &&
+ postData
+ ) {
+ component = SourcePreview;
+ componentProps = {
+ text: postData,
+ mode: mimeType?.replace(/;.+/, ""),
+ targetSearchResult,
+ };
+ requestPayloadLabel = REQUEST_POST_PAYLOAD;
+ }
+
+ return div(
+ { className: "panel-container" },
+ error && div({ className: "request-error-header", title: error }, error),
+ div(
+ { className: "devtools-toolbar devtools-input-toolbar" },
+ SearchBox({
+ delay: FILTER_SEARCH_DELAY,
+ type: "filter",
+ onChange: text => this.setState({ filterText: text }),
+ placeholder: REQUEST_FILTER_TEXT,
+ })
+ ),
+ h2({ className: "data-header", role: "heading" }, [
+ span(
+ {
+ key: "data-label",
+ className: "data-label",
+ },
+ requestPayloadLabel
+ ),
+ hasFormattedDisplay &&
+ this.renderRawRequestPayloadBtn(
+ "request",
+ rawRequestPayloadDisplayed,
+ this.toggleRawRequestPayload
+ ),
+ ]),
+ this.renderRequestPayload(component, componentProps)
+ );
+ }
+}
+
+module.exports = connect(null, dispatch => ({
+ updateRequest: (id, data, batch) =>
+ dispatch(Actions.updateRequest(id, data, batch)),
+}))(RequestPanel);
diff --git a/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js b/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js
new file mode 100644
index 0000000000..ac4435ea1d
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/ResponsePanel.js
@@ -0,0 +1,496 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ decodeUnicodeBase64,
+ fetchNetworkUpdatePacket,
+ parseJSON,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ getCORSErrorURL,
+} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
+const {
+ Filters,
+} = require("resource://devtools/client/netmonitor/src/utils/filter-predicates.js");
+const {
+ FILTER_SEARCH_DELAY,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+const {
+ BLOCKED_REASON_MESSAGES,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+
+// Components
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+const ImagePreview = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/ImagePreview.js")
+);
+const FontPreview = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/FontPreview.js")
+);
+const SourcePreview = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/SourcePreview.js")
+);
+const HtmlPreview = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/previews/HtmlPreview.js")
+);
+let {
+ NotificationBox,
+ PriorityLevels,
+} = require("resource://devtools/client/shared/components/NotificationBox.js");
+NotificationBox = createFactory(NotificationBox);
+const MessagesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/messages/MessagesView.js")
+);
+const SearchBox = createFactory(
+ require("resource://devtools/client/shared/components/SearchBox.js")
+);
+
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+
+const { div, input, label, span, h2 } = dom;
+const JSON_SCOPE_NAME = L10N.getStr("jsonScopeName");
+const JSON_FILTER_TEXT = L10N.getStr("jsonFilterText");
+const RESPONSE_PAYLOAD = L10N.getStr("responsePayload");
+const RAW_RESPONSE_PAYLOAD = L10N.getStr("netmonitor.response.raw");
+const HTML_RESPONSE = L10N.getStr("netmonitor.response.html");
+const RESPONSE_EMPTY_TEXT = L10N.getStr("responseEmptyText");
+const RESPONSE_TRUNCATED = L10N.getStr("responseTruncated");
+
+const JSON_VIEW_MIME_TYPE = "application/vnd.mozilla.json.view";
+
+/**
+ * Response panel component
+ * Displays the GET parameters and POST data of a request
+ */
+class ResponsePanel extends Component {
+ static get propTypes() {
+ return {
+ request: PropTypes.object.isRequired,
+ openLink: PropTypes.func,
+ targetSearchResult: PropTypes.object,
+ connector: PropTypes.object.isRequired,
+ showMessagesView: PropTypes.bool,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ filterText: "",
+ rawResponsePayloadDisplayed: !!props.targetSearchResult,
+ };
+
+ this.toggleRawResponsePayload = this.toggleRawResponsePayload.bind(this);
+ this.renderCORSBlockedReason = this.renderCORSBlockedReason.bind(this);
+ this.renderRawResponsePayloadBtn =
+ this.renderRawResponsePayloadBtn.bind(this);
+ this.renderJsonHtmlAndSource = this.renderJsonHtmlAndSource.bind(this);
+ this.handleJSONResponse = this.handleJSONResponse.bind(this);
+ }
+
+ componentDidMount() {
+ const { request, connector } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "responseContent",
+ ]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { request, connector } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, [
+ "responseContent",
+ ]);
+
+ // If the response contains XSSI stripped chars default to raw view
+ const text = nextProps.request?.responseContent?.content?.text;
+ const xssiStrippedChars = text && parseJSON(text)?.strippedChars;
+ if (xssiStrippedChars && !this.state.rawResponsePayloadDisplayed) {
+ this.toggleRawResponsePayload();
+ }
+
+ if (nextProps.targetSearchResult !== null) {
+ this.setState({
+ rawResponsePayloadDisplayed: !!nextProps.targetSearchResult,
+ });
+ }
+ }
+
+ /**
+ * Update only if:
+ * 1) The rendered object has changed
+ * 2) The user selected another search result target.
+ * 3) Internal state changes
+ */
+ shouldComponentUpdate(nextProps, nextState) {
+ return (
+ this.state !== nextState ||
+ this.props.request !== nextProps.request ||
+ nextProps.targetSearchResult !== null
+ );
+ }
+
+ /**
+ * Handle json, which we tentatively identify by checking the
+ * MIME type for "json" after any word boundary. This works
+ * for the standard "application/json", and also for custom
+ * types like "x-bigcorp-json". Additionally, we also
+ * directly parse the response text content to verify whether
+ * it's json or not, to handle responses incorrectly labeled
+ * as text/plain instead.
+ */
+ handleJSONResponse(mimeType, response) {
+ const limit = Services.prefs.getIntPref(
+ "devtools.netmonitor.responseBodyLimit"
+ );
+ const { request } = this.props;
+
+ // Check if the response has been truncated, in which case no parse should
+ // be attempted.
+ if (limit > 0 && limit <= request.responseContent.content.size) {
+ const result = {};
+ result.error = RESPONSE_TRUNCATED;
+ return result;
+ }
+
+ const { json, error, jsonpCallback, strippedChars } = parseJSON(response);
+
+ if (/\bjson/.test(mimeType) || json) {
+ const result = {};
+ // Make sure this is a valid JSON object first. If so, nicely display
+ // the parsing results in a tree view.
+
+ // Valid JSON
+ if (json) {
+ result.json = json;
+ }
+ // Valid JSONP
+ if (jsonpCallback) {
+ result.jsonpCallback = jsonpCallback;
+ }
+ // Malformed JSON
+ if (error) {
+ result.error = "" + error;
+ }
+ // XSSI protection sequence
+ if (strippedChars) {
+ result.strippedChars = strippedChars;
+ }
+
+ return result;
+ }
+
+ return null;
+ }
+
+ renderCORSBlockedReason(blockedReason) {
+ // ensure that the blocked reason is in the CORS range
+ if (
+ typeof blockedReason != "number" ||
+ blockedReason < 1000 ||
+ blockedReason > 1015
+ ) {
+ return null;
+ }
+
+ const blockedMessage = BLOCKED_REASON_MESSAGES[blockedReason];
+ const messageText = L10N.getFormatStr(
+ "netmonitor.headers.blockedByCORS",
+ blockedMessage
+ );
+
+ const learnMoreTooltip = L10N.getStr(
+ "netmonitor.headers.blockedByCORSTooltip"
+ );
+
+ // Create a notifications map with the CORS error notification
+ const notifications = new Map();
+ notifications.set("CORS-error", {
+ label: messageText,
+ value: "CORS-error",
+ image: "",
+ priority: PriorityLevels.PRIORITY_INFO_HIGH,
+ type: "info",
+ eventCallback: e => {},
+ buttons: [
+ {
+ mdnUrl: getCORSErrorURL(blockedReason),
+ label: learnMoreTooltip,
+ },
+ ],
+ });
+
+ return NotificationBox({
+ notifications,
+ displayBorderTop: false,
+ displayBorderBottom: true,
+ displayCloseButton: false,
+ });
+ }
+
+ toggleRawResponsePayload() {
+ this.setState({
+ rawResponsePayloadDisplayed: !this.state.rawResponsePayloadDisplayed,
+ });
+ }
+
+ /**
+ * Pick correct component, componentprops, and other needed data to render
+ * the given response
+ *
+ * @returns {Object} shape:
+ * {component}: React component used to render response
+ * {Object} componetProps: Props passed to component
+ * {Error} error: JSON parsing error
+ * {Object} json: parsed JSON payload
+ * {bool} hasFormattedDisplay: whether the given payload has a formatted
+ * display or if it should be rendered raw
+ * {string} responsePayloadLabel: describes type in response panel
+ * {component} xssiStrippedCharsInfoBox: React component to notifiy users
+ * that XSSI characters were stripped from the response
+ */
+ renderJsonHtmlAndSource() {
+ const { request, targetSearchResult } = this.props;
+ const { responseContent } = request;
+ let { encoding, mimeType, text } = responseContent.content;
+ const { filterText, rawResponsePayloadDisplayed } = this.state;
+
+ // Decode response if it's coming from JSONView.
+ if (mimeType?.includes(JSON_VIEW_MIME_TYPE) && encoding === "base64") {
+ text = decodeUnicodeBase64(text);
+ }
+ const { json, jsonpCallback, error, strippedChars } =
+ this.handleJSONResponse(mimeType, text) || {};
+
+ let component;
+ let componentProps;
+ let xssiStrippedCharsInfoBox;
+ let responsePayloadLabel = RESPONSE_PAYLOAD;
+ let hasFormattedDisplay = false;
+
+ if (json) {
+ if (jsonpCallback) {
+ responsePayloadLabel = L10N.getFormatStr(
+ "jsonpScopeName",
+ jsonpCallback
+ );
+ } else {
+ responsePayloadLabel = JSON_SCOPE_NAME;
+ }
+
+ // If raw response payload is not displayed render xssi info box if
+ // there are stripped chars
+ if (!rawResponsePayloadDisplayed) {
+ xssiStrippedCharsInfoBox =
+ this.renderXssiStrippedCharsInfoBox(strippedChars);
+ } else {
+ xssiStrippedCharsInfoBox = null;
+ }
+
+ component = PropertiesView;
+ componentProps = {
+ object: json,
+ useQuotes: true,
+ filterText,
+ targetSearchResult,
+ defaultSelectFirstNode: false,
+ mode: MODE.LONG,
+ useBaseTreeViewExpand: true,
+ };
+ hasFormattedDisplay = true;
+ } else if (Filters.html(this.props.request)) {
+ // Display HTML
+ responsePayloadLabel = HTML_RESPONSE;
+ component = HtmlPreview;
+ componentProps = { responseContent };
+ hasFormattedDisplay = true;
+ }
+ if (!hasFormattedDisplay || rawResponsePayloadDisplayed) {
+ component = SourcePreview;
+ componentProps = {
+ text,
+ mode: json ? "application/json" : mimeType.replace(/;.+/, ""),
+ targetSearchResult,
+ };
+ }
+ return {
+ component,
+ componentProps,
+ error,
+ hasFormattedDisplay,
+ json,
+ responsePayloadLabel,
+ xssiStrippedCharsInfoBox,
+ };
+ }
+
+ renderRawResponsePayloadBtn(key, checked, onChange) {
+ return [
+ label(
+ {
+ key: `${key}RawResponsePayloadBtn`,
+ className: "raw-data-toggle",
+ htmlFor: `raw-${key}-checkbox`,
+ onClick: event => {
+ // stop the header click event
+ event.stopPropagation();
+ },
+ },
+ span({ className: "raw-data-toggle-label" }, RAW_RESPONSE_PAYLOAD),
+ span(
+ { className: "raw-data-toggle-input" },
+ input({
+ id: `raw-${key}-checkbox`,
+ checked,
+ className: "devtools-checkbox-toggle",
+ onChange,
+ type: "checkbox",
+ })
+ )
+ ),
+ ];
+ }
+
+ renderResponsePayload(component, componentProps) {
+ return component(componentProps);
+ }
+
+ /**
+ * This function takes a string of the XSSI protection characters
+ * removed from a JSON payload and produces a notification component
+ * letting the user know that they were removed
+ *
+ * @param {string} strippedChars: string of XSSI protection characters
+ * removed from JSON payload
+ * @returns {component} NotificationBox component
+ */
+ renderXssiStrippedCharsInfoBox(strippedChars) {
+ if (!strippedChars || this.state.rawRequestPayloadDisplayed) {
+ return null;
+ }
+ const message = L10N.getFormatStr("jsonXssiStripped", strippedChars);
+
+ const notifications = new Map();
+ notifications.set("xssi-string-removed-info-box", {
+ label: message,
+ value: "xssi-string-removed-info-box",
+ image: "",
+ priority: PriorityLevels.PRIORITY_INFO_MEDIUM,
+ type: "info",
+ eventCallback: e => {},
+ buttons: [],
+ });
+
+ return NotificationBox({
+ notifications,
+ displayBorderTop: false,
+ displayBorderBottom: true,
+ displayCloseButton: false,
+ });
+ }
+
+ render() {
+ const { connector, showMessagesView, request } = this.props;
+ const { blockedReason, responseContent, url } = request;
+ const { filterText, rawResponsePayloadDisplayed } = this.state;
+
+ // Display CORS blocked Reason info box
+ const CORSBlockedReasonDetails =
+ this.renderCORSBlockedReason(blockedReason);
+
+ if (showMessagesView) {
+ return MessagesView({ connector });
+ }
+
+ if (
+ !responseContent ||
+ typeof responseContent.content.text !== "string" ||
+ !responseContent.content.text
+ ) {
+ return div(
+ { className: "panel-container" },
+ CORSBlockedReasonDetails,
+ div({ className: "empty-notice" }, RESPONSE_EMPTY_TEXT)
+ );
+ }
+
+ const { encoding, mimeType, text } = responseContent.content;
+
+ if (Filters.images({ mimeType })) {
+ return ImagePreview({ encoding, mimeType, text, url });
+ }
+
+ if (Filters.fonts({ url, mimeType })) {
+ return FontPreview({ connector, mimeType, url });
+ }
+
+ // Get Data needed for formatted display
+ const {
+ component,
+ componentProps,
+ error,
+ hasFormattedDisplay,
+ json,
+ responsePayloadLabel,
+ xssiStrippedCharsInfoBox,
+ } = this.renderJsonHtmlAndSource();
+
+ const classList = ["panel-container"];
+ if (Filters.html(this.props.request)) {
+ classList.push("contains-html-preview");
+ }
+
+ return div(
+ { className: classList.join(" ") },
+ error && div({ className: "response-error-header", title: error }, error),
+ json &&
+ div(
+ { className: "devtools-toolbar devtools-input-toolbar" },
+ SearchBox({
+ delay: FILTER_SEARCH_DELAY,
+ type: "filter",
+ onChange: filter => this.setState({ filterText: filter }),
+ placeholder: JSON_FILTER_TEXT,
+ value: filterText,
+ })
+ ),
+ div({ tabIndex: "0" }, CORSBlockedReasonDetails),
+ h2({ className: "data-header", role: "heading" }, [
+ span(
+ {
+ key: "data-label",
+ className: "data-label",
+ },
+ responsePayloadLabel
+ ),
+ hasFormattedDisplay &&
+ this.renderRawResponsePayloadBtn(
+ "response",
+ rawResponsePayloadDisplayed,
+ this.toggleRawResponsePayload
+ ),
+ ]),
+ xssiStrippedCharsInfoBox,
+ this.renderResponsePayload(component, componentProps)
+ );
+ }
+}
+
+module.exports = ResponsePanel;
diff --git a/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js b/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js
new file mode 100644
index 0000000000..74dca02cc8
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/SecurityPanel.js
@@ -0,0 +1,283 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ fetchNetworkUpdatePacket,
+ getUrlHost,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+
+// Components
+const TreeViewClass = require("resource://devtools/client/shared/components/tree/TreeView.js");
+const PropertiesView = createFactory(
+ require("resource://devtools/client/netmonitor/src/components/request-details/PropertiesView.js")
+);
+
+loader.lazyGetter(this, "Rep", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .REPS.Rep;
+});
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+
+const { div, span } = dom;
+const NOT_AVAILABLE = L10N.getStr("netmonitor.security.notAvailable");
+const ERROR_LABEL = L10N.getStr("netmonitor.security.error");
+const CIPHER_SUITE_LABEL = L10N.getStr("netmonitor.security.cipherSuite");
+const WARNING_CIPHER_LABEL = L10N.getStr("netmonitor.security.warning.cipher");
+const ENABLED_LABEL = L10N.getStr("netmonitor.security.enabled");
+const DISABLED_LABEL = L10N.getStr("netmonitor.security.disabled");
+const CONNECTION_LABEL = L10N.getStr("netmonitor.security.connection");
+const PROTOCOL_VERSION_LABEL = L10N.getStr(
+ "netmonitor.security.protocolVersion"
+);
+const KEA_GROUP_LABEL = L10N.getStr("netmonitor.security.keaGroup");
+const KEA_GROUP_NONE = L10N.getStr("netmonitor.security.keaGroup.none");
+const KEA_GROUP_CUSTOM = L10N.getStr("netmonitor.security.keaGroup.custom");
+const KEA_GROUP_UNKNOWN = L10N.getStr("netmonitor.security.keaGroup.unknown");
+const SIGNATURE_SCHEME_LABEL = L10N.getStr(
+ "netmonitor.security.signatureScheme"
+);
+const SIGNATURE_SCHEME_NONE = L10N.getStr(
+ "netmonitor.security.signatureScheme.none"
+);
+const SIGNATURE_SCHEME_UNKNOWN = L10N.getStr(
+ "netmonitor.security.signatureScheme.unknown"
+);
+const HSTS_LABEL = L10N.getStr("netmonitor.security.hsts");
+const HPKP_LABEL = L10N.getStr("netmonitor.security.hpkp");
+const CERTIFICATE_LABEL = L10N.getStr("netmonitor.security.certificate");
+const CERTIFICATE_TRANSPARENCY_LABEL = L10N.getStr(
+ "certmgr.certificateTransparency.label"
+);
+const CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT = L10N.getStr(
+ "certmgr.certificateTransparency.status.ok"
+);
+const CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS = L10N.getStr(
+ "certmgr.certificateTransparency.status.notEnoughSCTS"
+);
+const CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS = L10N.getStr(
+ "certmgr.certificateTransparency.status.notDiverseSCTS"
+);
+const SUBJECT_INFO_LABEL = L10N.getStr("certmgr.subjectinfo.label");
+const CERT_DETAIL_COMMON_NAME_LABEL = L10N.getStr("certmgr.certdetail.cn");
+const CERT_DETAIL_ORG_LABEL = L10N.getStr("certmgr.certdetail.o");
+const CERT_DETAIL_ORG_UNIT_LABEL = L10N.getStr("certmgr.certdetail.ou");
+const ISSUER_INFO_LABEL = L10N.getStr("certmgr.issuerinfo.label");
+const PERIOD_OF_VALIDITY_LABEL = L10N.getStr("certmgr.periodofvalidity.label");
+const BEGINS_LABEL = L10N.getStr("certmgr.begins");
+const EXPIRES_LABEL = L10N.getStr("certmgr.expires");
+const FINGERPRINTS_LABEL = L10N.getStr("certmgr.fingerprints.label");
+const SHA256_FINGERPRINT_LABEL = L10N.getStr(
+ "certmgr.certdetail.sha256fingerprint"
+);
+const SHA1_FINGERPRINT_LABEL = L10N.getStr(
+ "certmgr.certdetail.sha1fingerprint"
+);
+
+/*
+ * Localize special values for key exchange group name,
+ * certificate signature scheme, and certificate
+ * transparency status.
+ */
+const formatSecurityInfo = securityInfo => {
+ const formattedSecurityInfo = { ...securityInfo };
+
+ const formatters = {
+ keaGroupName: value => {
+ if (value === "none") {
+ return KEA_GROUP_NONE;
+ }
+ if (value === "custom") {
+ return KEA_GROUP_CUSTOM;
+ }
+ if (value === "unknown group") {
+ return KEA_GROUP_UNKNOWN;
+ }
+ return value;
+ },
+ signatureSchemeName: value => {
+ if (value === "none") {
+ return SIGNATURE_SCHEME_NONE;
+ }
+ if (value === "unknown signature") {
+ return SIGNATURE_SCHEME_UNKNOWN;
+ }
+ return value;
+ },
+ certificateTransparency: value => {
+ if (value === 5) {
+ return CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT;
+ }
+ if (value === 6) {
+ return CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS;
+ }
+ if (value === 7) {
+ return CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS;
+ }
+ return value;
+ },
+ };
+
+ return Object.keys(formatters).reduce((acc, key) => {
+ const formatter = formatters[key];
+ acc[key] = formatter(acc[key]);
+ return acc;
+ }, formattedSecurityInfo);
+};
+
+const getConnectionLabel = securityInfo => ({
+ [PROTOCOL_VERSION_LABEL]: securityInfo.protocolVersion || NOT_AVAILABLE,
+ [CIPHER_SUITE_LABEL]: securityInfo.cipherSuite || NOT_AVAILABLE,
+ [KEA_GROUP_LABEL]: securityInfo.keaGroupName || NOT_AVAILABLE,
+ [SIGNATURE_SCHEME_LABEL]: securityInfo.signatureSchemeName || NOT_AVAILABLE,
+});
+
+const getHostHeaderLabel = securityInfo => ({
+ [HSTS_LABEL]: securityInfo.hsts ? ENABLED_LABEL : DISABLED_LABEL,
+ [HPKP_LABEL]: securityInfo.hpkp ? ENABLED_LABEL : DISABLED_LABEL,
+});
+
+const getCertificateLabel = securityInfo => {
+ const { fingerprint, issuer, subject, validity } = securityInfo.cert;
+
+ return {
+ [SUBJECT_INFO_LABEL]: {
+ [CERT_DETAIL_COMMON_NAME_LABEL]: subject?.commonName || NOT_AVAILABLE,
+ [CERT_DETAIL_ORG_LABEL]: subject?.organization || NOT_AVAILABLE,
+ [CERT_DETAIL_ORG_UNIT_LABEL]: subject?.organizationUnit || NOT_AVAILABLE,
+ },
+ [ISSUER_INFO_LABEL]: {
+ [CERT_DETAIL_COMMON_NAME_LABEL]: issuer?.commonName || NOT_AVAILABLE,
+ [CERT_DETAIL_ORG_LABEL]: issuer?.organization || NOT_AVAILABLE,
+ [CERT_DETAIL_ORG_UNIT_LABEL]: issuer?.organizationUnit || NOT_AVAILABLE,
+ },
+ [PERIOD_OF_VALIDITY_LABEL]: {
+ [BEGINS_LABEL]: validity?.start || NOT_AVAILABLE,
+ [EXPIRES_LABEL]: validity?.end || NOT_AVAILABLE,
+ },
+ [FINGERPRINTS_LABEL]: {
+ [SHA256_FINGERPRINT_LABEL]: fingerprint?.sha256 || NOT_AVAILABLE,
+ [SHA1_FINGERPRINT_LABEL]: fingerprint?.sha1 || NOT_AVAILABLE,
+ },
+ [CERTIFICATE_TRANSPARENCY_LABEL]:
+ securityInfo.certificateTransparency || NOT_AVAILABLE,
+ };
+};
+
+const getObject = ({ securityInfo, url }) => {
+ if (securityInfo.state !== "secure" && securityInfo.state !== "weak") {
+ return {
+ [ERROR_LABEL]: securityInfo.errorMessage || NOT_AVAILABLE,
+ };
+ }
+
+ const HOST_HEADER_LABEL = L10N.getFormatStr(
+ "netmonitor.security.hostHeader",
+ getUrlHost(url)
+ );
+ const formattedSecurityInfo = formatSecurityInfo(securityInfo);
+
+ return {
+ [CONNECTION_LABEL]: getConnectionLabel(formattedSecurityInfo),
+ [HOST_HEADER_LABEL]: getHostHeaderLabel(formattedSecurityInfo),
+ [CERTIFICATE_LABEL]: getCertificateLabel(formattedSecurityInfo),
+ };
+};
+
+/*
+ * Security panel component
+ * If the site is being served over HTTPS, you get an extra tab labeled "Security".
+ * This contains details about the secure connection used including the protocol,
+ * the cipher suite, and certificate details
+ */
+class SecurityPanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ openLink: PropTypes.func,
+ request: PropTypes.object.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ const { request, connector } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["securityInfo"]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { request, connector } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["securityInfo"]);
+ }
+
+ renderValue(props, weaknessReasons = []) {
+ const { member, value } = props;
+
+ // Hide object summary
+ if (typeof member.value === "object") {
+ return null;
+ }
+
+ return span(
+ { className: "security-info-value" },
+ member.name === ERROR_LABEL
+ ? // Display multiline text for security error for a label using a rep.
+ value
+ : Rep(
+ Object.assign(props, {
+ // FIXME: A workaround for the issue in StringRep
+ // Force StringRep to crop the text everytime
+ member: Object.assign({}, member, { open: false }),
+ mode: MODE.TINY,
+ cropLimit: 60,
+ noGrip: true,
+ })
+ ),
+ weaknessReasons.includes("cipher") && member.name === CIPHER_SUITE_LABEL
+ ? // Display an extra warning icon after the cipher suite
+ div({
+ id: "security-warning-cipher",
+ className: "security-warning-icon",
+ title: WARNING_CIPHER_LABEL,
+ })
+ : null
+ );
+ }
+
+ render() {
+ const { request } = this.props;
+ const { securityInfo, url } = request;
+
+ if (!securityInfo || !url) {
+ return null;
+ }
+
+ const object = getObject({ securityInfo, url });
+ return div(
+ { className: "panel-container security-panel" },
+ PropertiesView({
+ object,
+ renderValue: props =>
+ this.renderValue(props, securityInfo.weaknessReasons),
+ enableFilter: false,
+ expandedNodes: TreeViewClass.getExpandedNodes(object),
+ })
+ );
+ }
+}
+
+module.exports = SecurityPanel;
diff --git a/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js b/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js
new file mode 100644
index 0000000000..6b01ee27b7
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/StackTracePanel.js
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ Component,
+ createFactory,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const {
+ fetchNetworkUpdatePacket,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+
+const { div } = dom;
+
+// Components
+const StackTrace = createFactory(
+ require("resource://devtools/client/shared/components/StackTrace.js")
+);
+
+/**
+ * This component represents a side panel responsible for
+ * rendering stack-trace info for selected request.
+ */
+class StackTracePanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ request: PropTypes.object.isRequired,
+ sourceMapURLService: PropTypes.object,
+ openLink: PropTypes.func,
+ };
+ }
+
+ /**
+ * `componentDidMount` is called when opening the StackTracePanel
+ * for the first time
+ */
+ componentDidMount() {
+ const { request, connector } = this.props;
+ if (!request.stacktrace) {
+ fetchNetworkUpdatePacket(connector.requestData, request, ["stackTrace"]);
+ }
+ }
+
+ /**
+ * `componentWillReceiveProps` is the only method called when
+ * switching between two requests while this panel is displayed.
+ */
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { request, connector } = nextProps;
+ // Only try to fetch the stacktrace if we don't already have the stacktrace yet
+ if (!request.stacktrace) {
+ fetchNetworkUpdatePacket(connector.requestData, request, ["stackTrace"]);
+ }
+ }
+
+ render() {
+ const { connector, openLink, request, sourceMapURLService } = this.props;
+
+ const { stacktrace } = request;
+
+ return div(
+ { className: "panel-container" },
+ StackTrace({
+ stacktrace: stacktrace || [],
+ onViewSourceInDebugger: ({ url, line, column }) => {
+ return connector.viewSourceInDebugger(url, line, column);
+ },
+ sourceMapURLService,
+ openLink,
+ })
+ );
+ }
+}
+
+module.exports = StackTracePanel;
diff --git a/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js b/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js
new file mode 100644
index 0000000000..30053f09ea
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/TimingsPanel.js
@@ -0,0 +1,229 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {
+ connect,
+} = require("resource://devtools/client/shared/redux/visibility-handler-connect.js");
+const {
+ Component,
+} = require("resource://devtools/client/shared/vendor/react.js");
+const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.js");
+const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js");
+const {
+ L10N,
+} = require("resource://devtools/client/netmonitor/src/utils/l10n.js");
+const {
+ getNetMonitorTimingsURL,
+} = require("resource://devtools/client/netmonitor/src/utils/doc-utils.js");
+const {
+ fetchNetworkUpdatePacket,
+} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js");
+const {
+ getFormattedTime,
+} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js");
+const {
+ TIMING_KEYS,
+} = require("resource://devtools/client/netmonitor/src/constants.js");
+
+// Components
+const MDNLink = require("resource://devtools/client/shared/components/MdnLink.js");
+
+const { div, span } = dom;
+
+const TIMINGS_END_PADDING = "80px";
+
+/**
+ * Timings panel component
+ * Display timeline bars that shows the total wait time for various stages
+ */
+class TimingsPanel extends Component {
+ static get propTypes() {
+ return {
+ connector: PropTypes.object.isRequired,
+ request: PropTypes.object.isRequired,
+ firstRequestStartedMs: PropTypes.number.isRequired,
+ };
+ }
+
+ componentDidMount() {
+ const { connector, request } = this.props;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["eventTimings"]);
+ }
+
+ // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507
+ UNSAFE_componentWillReceiveProps(nextProps) {
+ const { connector, request } = nextProps;
+ fetchNetworkUpdatePacket(connector.requestData, request, ["eventTimings"]);
+ }
+
+ renderServerTimings() {
+ const { serverTimings, totalTime } = this.props.request.eventTimings;
+
+ if (!serverTimings?.length) {
+ return null;
+ }
+
+ return div(
+ {},
+ div(
+ { className: "label-separator" },
+ L10N.getStr("netmonitor.timings.serverTiming")
+ ),
+ ...serverTimings.map(({ name, duration, description }, index) => {
+ const color = name === "total" ? "total" : (index % 3) + 1;
+
+ return div(
+ {
+ key: index,
+ className: "tabpanel-summary-container timings-container server",
+ },
+ span(
+ { className: "tabpanel-summary-label timings-label" },
+ description || name
+ ),
+ div(
+ { className: "requests-list-timings-container" },
+ span({
+ className: "requests-list-timings-offset",
+ style: {
+ width: `calc(${
+ (totalTime - duration) / totalTime
+ } * (100% - ${TIMINGS_END_PADDING})`,
+ },
+ }),
+ span({
+ className: `requests-list-timings-box server-timings-color-${color}`,
+ style: {
+ width: `calc(${
+ duration / totalTime
+ } * (100% - ${TIMINGS_END_PADDING}))`,
+ },
+ }),
+ span(
+ { className: "requests-list-timings-total" },
+ getFormattedTime(duration)
+ )
+ )
+ );
+ })
+ );
+ }
+
+ render() {
+ const { eventTimings, totalTime, startedMs } = this.props.request;
+ const { firstRequestStartedMs } = this.props;
+
+ if (!eventTimings) {
+ return div(
+ {
+ className:
+ "tabpanel-summary-container timings-container empty-notice",
+ },
+ L10N.getStr("netmonitor.timings.noTimings")
+ );
+ }
+
+ const { timings, offsets } = eventTimings;
+ let queuedAt, startedAt, downloadedAt;
+ const isFirstRequestStartedAvailable = firstRequestStartedMs !== null;
+
+ if (isFirstRequestStartedAvailable) {
+ queuedAt = startedMs - firstRequestStartedMs;
+ startedAt = queuedAt + timings.blocked;
+ downloadedAt = queuedAt + totalTime;
+ }
+
+ const timelines = TIMING_KEYS.map((type, idx) => {
+ // Determine the relative offset for each timings box. For example, the
+ // offset of third timings box will be 0 + blocked offset + dns offset
+ // If offsets sent from the backend aren't available calculate it
+ // from the timing info.
+ const offset = offsets
+ ? offsets[type]
+ : TIMING_KEYS.slice(0, idx).reduce(
+ (acc, cur) => acc + timings[cur] || 0,
+ 0
+ );
+
+ const offsetScale = offset / totalTime || 0;
+ const timelineScale = timings[type] / totalTime || 0;
+
+ return div(
+ {
+ key: type,
+ id: `timings-summary-${type}`,
+ className: "tabpanel-summary-container timings-container request",
+ },
+ span(
+ { className: "tabpanel-summary-label timings-label" },
+ L10N.getStr(`netmonitor.timings.${type}`)
+ ),
+ div(
+ { className: "requests-list-timings-container" },
+ span({
+ className: "requests-list-timings-offset",
+ style: {
+ width: `calc(${offsetScale} * (100% - ${TIMINGS_END_PADDING})`,
+ },
+ }),
+ span({
+ className: `requests-list-timings-box ${type}`,
+ style: {
+ width: `calc(${timelineScale} * (100% - ${TIMINGS_END_PADDING}))`,
+ },
+ }),
+ span(
+ { className: "requests-list-timings-total" },
+ getFormattedTime(timings[type])
+ )
+ )
+ );
+ });
+
+ return div(
+ { className: "panel-container" },
+ isFirstRequestStartedAvailable &&
+ div(
+ { className: "timings-overview" },
+ span(
+ { className: "timings-overview-item" },
+ L10N.getFormatStr(
+ "netmonitor.timings.queuedAt",
+ getFormattedTime(queuedAt)
+ )
+ ),
+ span(
+ { className: "timings-overview-item" },
+ L10N.getFormatStr(
+ "netmonitor.timings.startedAt",
+ getFormattedTime(startedAt)
+ )
+ ),
+ span(
+ { className: "timings-overview-item" },
+ L10N.getFormatStr(
+ "netmonitor.timings.downloadedAt",
+ getFormattedTime(downloadedAt)
+ )
+ )
+ ),
+ div(
+ { className: "label-separator" },
+ L10N.getStr("netmonitor.timings.requestTiming")
+ ),
+ timelines,
+ this.renderServerTimings(),
+ MDNLink({
+ url: getNetMonitorTimingsURL(),
+ title: L10N.getStr("netmonitor.timings.learnMore"),
+ })
+ );
+ }
+}
+
+module.exports = connect(state => ({
+ firstRequestStartedMs: state.requests ? state.requests.firstStartedMs : null,
+}))(TimingsPanel);
diff --git a/devtools/client/netmonitor/src/components/request-details/moz.build b/devtools/client/netmonitor/src/components/request-details/moz.build
new file mode 100644
index 0000000000..09896f2de3
--- /dev/null
+++ b/devtools/client/netmonitor/src/components/request-details/moz.build
@@ -0,0 +1,16 @@
+# 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/.
+
+DevToolsModules(
+ "CachePanel.js",
+ "CookiesPanel.js",
+ "HeadersPanel.js",
+ "NetworkDetailsBar.js",
+ "PropertiesView.js",
+ "RequestPanel.js",
+ "ResponsePanel.js",
+ "SecurityPanel.js",
+ "StackTracePanel.js",
+ "TimingsPanel.js",
+)