Bug 1362036 - Implement http inspection in new console; r=nchevobbe draft
authorJan Odvarko <odvarko@gmail.com>
Wed, 30 Aug 2017 15:16:39 +0200
changeset 655893 79f3cd0d99aee930b97ac1b0323e62bd9b6aa732
parent 653766 d10c97627b51a226e19d0fa801201897fe1932f6
child 655894 6322bf36f33fa31d7b46db5bb7e80f9dfd1c22ed
push id76983
push userjodvarko@mozilla.com
push dateWed, 30 Aug 2017 13:17:20 +0000
reviewersnchevobbe
bugs1362036
milestone57.0a1
Bug 1362036 - Implement http inspection in new console; r=nchevobbe MozReview-Commit-ID: FhYePLM2T3O
devtools/client/netmonitor/src/components/headers-panel.js
devtools/client/netmonitor/src/components/tabbox-panel.js
devtools/client/netmonitor/src/connector/firefox-connector.js
devtools/client/netmonitor/src/connector/firefox-data-provider.js
devtools/client/netmonitor/src/connector/moz.build
devtools/client/netmonitor/src/constants.js
devtools/client/netmonitor/src/reducers/requests.js
devtools/client/netmonitor/src/reducers/ui.js
devtools/client/themes/webconsole.css
devtools/client/webconsole/new-console-output/actions/messages.js
devtools/client/webconsole/new-console-output/components/console-output.js
devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
devtools/client/webconsole/new-console-output/constants.js
devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
devtools/client/webconsole/new-console-output/reducers/messages.js
devtools/client/webconsole/new-console-output/store.js
devtools/client/webconsole/new-console-output/types.js
devtools/client/webconsole/new-console-output/utils/messages.js
devtools/client/webconsole/panel.js
devtools/client/webconsole/webconsole-connection-proxy.js
devtools/client/webconsole/webconsole.xhtml
--- a/devtools/client/netmonitor/src/components/headers-panel.js
+++ b/devtools/client/netmonitor/src/components/headers-panel.js
@@ -198,21 +198,21 @@ const HeadersPanel = createClass({
             size: `${inputWidth}`,
           }),
           statusCodeDocURL ? MDNLink({
             url: statusCodeDocURL,
           }) : span({
             className: "headers-summary learn-more-link",
           }),
           button({
-            className: "devtools-button",
+            className: "devtools-button edit-and-resend-button",
             onClick: cloneSelectedRequest,
           }, EDIT_AND_RESEND),
           button({
-            className: "devtools-button",
+            className: "devtools-button raw-headers-button",
             onClick: this.toggleRawHeaders,
           }, RAW_HEADERS),
         )
       );
     }
 
     let summaryVersion = httpVersion ?
       this.renderSummary(SUMMARY_VERSION, httpVersion) : null;
--- a/devtools/client/netmonitor/src/components/tabbox-panel.js
+++ b/devtools/client/netmonitor/src/components/tabbox-panel.js
@@ -6,17 +6,17 @@
 
 const {
   createFactory,
   PropTypes,
 } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const Actions = require("../actions/index");
 const { L10N } = require("../utils/l10n");
-const { getSelectedRequest } = require("../selectors/index");
+const { PANELS } = require("../constants");
 
 // Components
 const Tabbar = createFactory(require("devtools/client/shared/components/tabs/tabbar"));
 const TabPanel = createFactory(require("devtools/client/shared/components/tabs/tabs").TabPanel);
 const CookiesPanel = createFactory(require("./cookies-panel"));
 const HeadersPanel = createFactory(require("./headers-panel"));
 const ParamsPanel = createFactory(require("./params-panel"));
 const ResponsePanel = createFactory(require("./response-panel"));
@@ -51,76 +51,74 @@ function TabboxPanel({
     Tabbar({
       activeTabId,
       menuDocument: window.parent.document,
       onSelect: selectTab,
       renderOnlySelected: true,
       showAllTabsMenu: true,
     },
       TabPanel({
-        id: "headers",
+        id: PANELS.HEADERS,
         title: HEADERS_TITLE,
       },
         HeadersPanel({ request, cloneSelectedRequest }),
       ),
       TabPanel({
-        id: "cookies",
+        id: PANELS.COOKIES,
         title: COOKIES_TITLE,
       },
         CookiesPanel({ request }),
       ),
       TabPanel({
-        id: "params",
+        id: PANELS.PARAMS,
         title: PARAMS_TITLE,
       },
         ParamsPanel({ request }),
       ),
       TabPanel({
-        id: "response",
+        id: PANELS.RESPONSE,
         title: RESPONSE_TITLE,
       },
         ResponsePanel({ request }),
       ),
       TabPanel({
-        id: "timings",
+        id: PANELS.TIMINGS,
         title: TIMINGS_TITLE,
       },
         TimingsPanel({ request }),
       ),
       request.cause && request.cause.stacktrace && request.cause.stacktrace.length > 0 &&
       TabPanel({
-        id: "stack-trace",
+        id: PANELS.STACK_TRACE,
         title: STACK_TRACE_TITLE,
       },
         StackTracePanel({ request, sourceMapService }),
       ),
       request.securityState && request.securityState !== "insecure" &&
       TabPanel({
-        id: "security",
+        id: PANELS.SECURITY,
         title: SECURITY_TITLE,
       },
         SecurityPanel({ request }),
       ),
     )
   );
 }
 
 TabboxPanel.displayName = "TabboxPanel";
 
 TabboxPanel.propTypes = {
   activeTabId: PropTypes.string,
-  cloneSelectedRequest: PropTypes.func.isRequired,
+  cloneSelectedRequest: PropTypes.func,
   request: PropTypes.object,
   selectTab: PropTypes.func.isRequired,
   // Service to enable the source map feature.
   sourceMapService: PropTypes.object,
 };
 
 module.exports = connect(
   (state) => ({
-    activeTabId: state.ui.detailsPanelSelectedTab,
-    request: getSelectedRequest(state),
   }),
   (dispatch) => ({
     cloneSelectedRequest: () => dispatch(Actions.cloneSelectedRequest()),
     selectTab: (tabId) => dispatch(Actions.selectDetailsPanelTab(tabId)),
   }),
 )(TabboxPanel);
--- a/devtools/client/netmonitor/src/connector/firefox-connector.js
+++ b/devtools/client/netmonitor/src/connector/firefox-connector.js
@@ -1,77 +1,59 @@
 /* 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 Services = require("Services");
-const { CurlUtils } = require("devtools/client/shared/curl");
 const { TimelineFront } = require("devtools/shared/fronts/timeline");
 const { ACTIVITY_TYPE, EVENTS } = require("../constants");
 const { getDisplayedRequestById } = require("../selectors/index");
-const { fetchHeaders, formDataURI } = require("../utils/request-utils");
+const FirefoxDataProvider = require("./firefox-data-provider");
 
 class FirefoxConnector {
   constructor() {
-    // Internal properties
-    this.payloadQueue = [];
-
     // Public methods
     this.connect = this.connect.bind(this);
     this.disconnect = this.disconnect.bind(this);
     this.willNavigate = this.willNavigate.bind(this);
     this.displayCachedEvents = this.displayCachedEvents.bind(this);
     this.onDocLoadingMarker = this.onDocLoadingMarker.bind(this);
-    this.addRequest = this.addRequest.bind(this);
-    this.updateRequest = this.updateRequest.bind(this);
-    this.fetchImage = this.fetchImage.bind(this);
-    this.fetchRequestHeaders = this.fetchRequestHeaders.bind(this);
-    this.fetchResponseHeaders = this.fetchResponseHeaders.bind(this);
-    this.fetchPostData = this.fetchPostData.bind(this);
-    this.fetchResponseCookies = this.fetchResponseCookies.bind(this);
-    this.fetchRequestCookies = this.fetchRequestCookies.bind(this);
-    this.getPayloadFromQueue = this.getPayloadFromQueue.bind(this);
-    this.isQueuePayloadReady = this.isQueuePayloadReady.bind(this);
-    this.pushPayloadToQueue = this.pushPayloadToQueue.bind(this);
     this.sendHTTPRequest = this.sendHTTPRequest.bind(this);
     this.setPreferences = this.setPreferences.bind(this);
     this.triggerActivity = this.triggerActivity.bind(this);
     this.inspectRequest = this.inspectRequest.bind(this);
-    this.getLongString = this.getLongString.bind(this);
-    this.getNetworkRequest = this.getNetworkRequest.bind(this);
     this.getTabTarget = this.getTabTarget.bind(this);
     this.viewSourceInDebugger = this.viewSourceInDebugger.bind(this);
 
-    // Event handlers
-    this.onNetworkEvent = this.onNetworkEvent.bind(this);
-    this.onNetworkEventUpdate = this.onNetworkEventUpdate.bind(this);
-    this.onRequestHeaders = this.onRequestHeaders.bind(this);
-    this.onRequestCookies = this.onRequestCookies.bind(this);
-    this.onRequestPostData = this.onRequestPostData.bind(this);
-    this.onSecurityInfo = this.onSecurityInfo.bind(this);
-    this.onResponseHeaders = this.onResponseHeaders.bind(this);
-    this.onResponseCookies = this.onResponseCookies.bind(this);
-    this.onResponseContent = this.onResponseContent.bind(this);
-    this.onEventTimings = this.onEventTimings.bind(this);
+    // Internals
+    this.getLongString = this.getLongString.bind(this);
+    this.getNetworkRequest = this.getNetworkRequest.bind(this);
   }
 
   async connect(connection, actions, getState) {
     this.actions = actions;
     this.getState = getState;
     this.tabTarget = connection.tabConnection.tabTarget;
     this.toolbox = connection.toolbox;
 
     this.webConsoleClient = this.tabTarget.activeConsole;
 
+    this.dataProvider = new FirefoxDataProvider({
+      webConsoleClient: this.webConsoleClient,
+      actions: this.actions,
+    });
+
     this.tabTarget.on("will-navigate", this.willNavigate);
     this.tabTarget.on("close", this.disconnect);
-    this.webConsoleClient.on("networkEvent", this.onNetworkEvent);
-    this.webConsoleClient.on("networkEventUpdate", this.onNetworkEventUpdate);
+    this.webConsoleClient.on("networkEvent",
+      this.dataProvider.onNetworkEvent);
+    this.webConsoleClient.on("networkEventUpdate",
+      this.dataProvider.onNetworkEventUpdate);
 
     // Don't start up waiting for timeline markers if the server isn't
     // recent enough to emit the markers we're interested in.
     if (this.tabTarget.getTrait("documentLoadingMarkers")) {
       this.timelineFront = new TimelineFront(this.tabTarget.client, this.tabTarget.form);
       this.timelineFront.on("doc-loading", this.onDocLoadingMarker);
       await this.timelineFront.start({ withDocLoadingEvents: true });
     }
@@ -91,16 +73,17 @@ class FirefoxConnector {
 
     this.tabTarget.off("will-navigate");
     this.tabTarget.off("close");
     this.tabTarget = null;
     this.webConsoleClient.off("networkEvent");
     this.webConsoleClient.off("networkEventUpdate");
     this.webConsoleClient = null;
     this.timelineFront = null;
+    this.dataProvider = null;
   }
 
   willNavigate() {
     if (!Services.prefs.getBoolPref("devtools.webconsole.persistlog")) {
       this.actions.batchReset();
       this.actions.clearRequests();
     } else {
       // If the log is persistent, just clear all accumulated timing markers.
@@ -109,20 +92,20 @@ class FirefoxConnector {
   }
 
   /**
    * Display any network events already in the cache.
    */
   displayCachedEvents() {
     for (let networkInfo of this.webConsoleClient.getNetworkEvents()) {
       // First add the request to the timeline.
-      this.onNetworkEvent("networkEvent", networkInfo);
+      this.dataProvider.onNetworkEvent("networkEvent", networkInfo);
       // Then replay any updates already received.
       for (let updateType of networkInfo.updates) {
-        this.onNetworkEventUpdate("networkEventUpdate", {
+        this.dataProvider.onNetworkEventUpdate("networkEventUpdate", {
           packet: { updateType },
           networkInfo,
         });
       }
     }
   }
 
   /**
@@ -131,232 +114,16 @@ class FirefoxConnector {
    * @param {object} marker
    */
   onDocLoadingMarker(marker) {
     window.emit(EVENTS.TIMELINE_EVENT, marker);
     this.actions.addTimingMarker(marker);
   }
 
   /**
-   * Add a new network request to application state.
-   *
-   * @param {string} id request id
-   * @param {object} data data payload will be added to application state
-   */
-  addRequest(id, data) {
-    let {
-      method,
-      url,
-      isXHR,
-      cause,
-      startedDateTime,
-      fromCache,
-      fromServiceWorker,
-    } = data;
-
-    this.actions.addRequest(
-      id,
-      {
-        // Convert the received date/time string to a unix timestamp.
-        startedMillis: Date.parse(startedDateTime),
-        method,
-        url,
-        isXHR,
-        cause,
-        fromCache,
-        fromServiceWorker,
-      },
-      true,
-    )
-    .then(() => window.emit(EVENTS.REQUEST_ADDED, id));
-  }
-
-  /**
-   * Update a network request if it already exists in application state.
-   *
-   * @param {string} id request id
-   * @param {object} data data payload will be updated to application state
-   */
-  async updateRequest(id, data) {
-    let {
-      mimeType,
-      responseContent,
-      responseCookies,
-      responseHeaders,
-      requestCookies,
-      requestHeaders,
-      requestPostData,
-    } = data;
-
-    // fetch request detail contents in parallel
-    let [
-      imageObj,
-      requestHeadersObj,
-      responseHeadersObj,
-      postDataObj,
-      requestCookiesObj,
-      responseCookiesObj,
-    ] = await Promise.all([
-      this.fetchImage(mimeType, responseContent),
-      this.fetchRequestHeaders(requestHeaders),
-      this.fetchResponseHeaders(responseHeaders),
-      this.fetchPostData(requestPostData),
-      this.fetchRequestCookies(requestCookies),
-      this.fetchResponseCookies(responseCookies),
-    ]);
-
-    let payload = Object.assign({}, data,
-                                    imageObj, requestHeadersObj, responseHeadersObj,
-                                    postDataObj, requestCookiesObj, responseCookiesObj);
-
-    this.pushPayloadToQueue(id, payload);
-
-    if (this.isQueuePayloadReady(id)) {
-      await this.actions.updateRequest(id, this.getPayloadFromQueue(id).payload, true);
-    }
-  }
-
-  async fetchImage(mimeType, responseContent) {
-    let payload = {};
-    if (mimeType && responseContent && responseContent.content) {
-      let { encoding, text } = responseContent.content;
-      let response = await this.getLongString(text);
-
-      if (mimeType.includes("image/")) {
-        payload.responseContentDataUri = formDataURI(mimeType, encoding, response);
-      }
-
-      responseContent.content.text = response;
-      payload.responseContent = responseContent;
-    }
-    return payload;
-  }
-
-  async fetchRequestHeaders(requestHeaders) {
-    let payload = {};
-    if (requestHeaders && requestHeaders.headers && requestHeaders.headers.length) {
-      let headers = await fetchHeaders(requestHeaders, this.getLongString);
-      if (headers) {
-        payload.requestHeaders = headers;
-      }
-    }
-    return payload;
-  }
-
-  async fetchResponseHeaders(responseHeaders) {
-    let payload = {};
-    if (responseHeaders && responseHeaders.headers && responseHeaders.headers.length) {
-      let headers = await fetchHeaders(responseHeaders, this.getLongString);
-      if (headers) {
-        payload.responseHeaders = headers;
-      }
-    }
-    return payload;
-  }
-
-  async fetchPostData(requestPostData) {
-    let payload = {};
-    if (requestPostData && requestPostData.postData) {
-      let { text } = requestPostData.postData;
-      let postData = await this.getLongString(text);
-      const headers = CurlUtils.getHeadersFromMultipartText(postData);
-      const headersSize = headers.reduce((acc, { name, value }) => {
-        return acc + name.length + value.length + 2;
-      }, 0);
-      requestPostData.postData.text = postData;
-      payload.requestPostData = Object.assign({}, requestPostData);
-      payload.requestHeadersFromUploadStream = { headers, headersSize };
-    }
-    return payload;
-  }
-
-  async fetchResponseCookies(responseCookies) {
-    let payload = {};
-    if (responseCookies) {
-      let resCookies = [];
-      // response store cookies in responseCookies or responseCookies.cookies
-      let cookies = responseCookies.cookies ?
-        responseCookies.cookies : responseCookies;
-      // make sure cookies is iterable
-      if (typeof cookies[Symbol.iterator] === "function") {
-        for (let cookie of cookies) {
-          resCookies.push(Object.assign({}, cookie, {
-            value: await this.getLongString(cookie.value),
-          }));
-        }
-        if (resCookies.length) {
-          payload.responseCookies = resCookies;
-        }
-      }
-    }
-    return payload;
-  }
-
-  async fetchRequestCookies(requestCookies) {
-    let payload = {};
-    if (requestCookies) {
-      let reqCookies = [];
-      // request store cookies in requestCookies or requestCookies.cookies
-      let cookies = requestCookies.cookies ?
-        requestCookies.cookies : requestCookies;
-      // make sure cookies is iterable
-      if (typeof cookies[Symbol.iterator] === "function") {
-        for (let cookie of cookies) {
-          reqCookies.push(Object.assign({}, cookie, {
-            value: await this.getLongString(cookie.value),
-          }));
-        }
-        if (reqCookies.length) {
-          payload.requestCookies = reqCookies;
-        }
-      }
-    }
-    return payload;
-  }
-
-  /**
-   * Access a payload item from payload queue.
-   *
-   * @param {string} id request id
-   * @return {boolean} return a queued payload item from queue.
-   */
-  getPayloadFromQueue(id) {
-    return this.payloadQueue.find((item) => item.id === id);
-  }
-
-  /**
-   * Packet order of "networkUpdateEvent" is predictable, as a result we can wait for
-   * the last one "eventTimings" packet arrives to check payload is ready.
-   *
-   * @param {string} id request id
-   * @return {boolean} return whether a specific networkEvent has been updated completely.
-   */
-  isQueuePayloadReady(id) {
-    let queuedPayload = this.getPayloadFromQueue(id);
-    return queuedPayload && queuedPayload.payload.eventTimings;
-  }
-
-  /**
-   * Push a request payload into a queue if request doesn't exist. Otherwise update the
-   * request itself.
-   *
-   * @param {string} id request id
-   * @param {object} payload request data payload
-   */
-  pushPayloadToQueue(id, payload) {
-    let queuedPayload = this.getPayloadFromQueue(id);
-    if (!queuedPayload) {
-      this.payloadQueue.push({ id, payload });
-    } else {
-      // Merge upcoming networkEventUpdate payload into existing one
-      queuedPayload.payload = Object.assign({}, queuedPayload.payload, payload);
-    }
-  }
-
-  /**
    * Send a HTTP request data payload
    *
    * @param {object} data data payload would like to sent to backend
    * @param {function} callback callback will be invoked after the request finished
    */
   sendHTTPRequest(data, callback) {
     this.webConsoleClient.sendHTTPRequest(data, callback);
   }
@@ -485,32 +252,32 @@ class FirefoxConnector {
 
   /**
    * Fetches the network information packet from actor server
    *
    * @param {string} id request id
    * @return {object} networkInfo data packet
    */
   getNetworkRequest(id) {
-    return this.webConsoleClient.getNetworkRequest(id);
+    return this.dataProvider.getNetworkRequest(id);
   }
 
   /**
    * Fetches the full text of a LongString.
    *
    * @param {object|string} stringGrip
    *        The long string grip containing the corresponding actor.
    *        If you pass in a plain string (by accident or because you're lazy),
    *        then a promise of the same string is simply returned.
    * @return {object}
    *         A promise that is resolved when the full string contents
    *         are available, or rejected if something goes wrong.
    */
   getLongString(stringGrip) {
-    return this.webConsoleClient.getString(stringGrip);
+    return this.dataProvider.getLongString(stringGrip);
   }
 
   /**
    * Getter that access tab target instance.
    * @return {object} browser tab target instance
    */
   getTabTarget() {
     return this.tabTarget;
@@ -521,218 +288,11 @@ class FirefoxConnector {
    * @param {string} sourceURL source url
    * @param {number} sourceLine source line number
    */
   viewSourceInDebugger(sourceURL, sourceLine) {
     if (this.toolbox) {
       this.toolbox.viewSourceInDebugger(sourceURL, sourceLine);
     }
   }
-
-  /**
-   * The "networkEvent" message type handler.
-   *
-   * @param {string} type message type
-   * @param {object} networkInfo network request information
-   */
-  onNetworkEvent(type, networkInfo) {
-    let {
-      actor,
-      cause,
-      fromCache,
-      fromServiceWorker,
-      isXHR,
-      request: {
-        method,
-        url,
-      },
-      startedDateTime,
-    } = networkInfo;
-
-    this.addRequest(actor, {
-      cause,
-      fromCache,
-      fromServiceWorker,
-      isXHR,
-      method,
-      startedDateTime,
-      url,
-    });
-
-    window.emit(EVENTS.NETWORK_EVENT, actor);
-  }
-
-  /**
-   * The "networkEventUpdate" message type handler.
-   *
-   * @param {string} type message type
-   * @param {object} packet the message received from the server.
-   * @param {object} networkInfo the network request information.
-   */
-  onNetworkEventUpdate(type, { packet, networkInfo }) {
-    let { actor } = networkInfo;
-
-    switch (packet.updateType) {
-      case "requestHeaders":
-        this.webConsoleClient.getRequestHeaders(actor, this.onRequestHeaders);
-        window.emit(EVENTS.UPDATING_REQUEST_HEADERS, actor);
-        break;
-      case "requestCookies":
-        this.webConsoleClient.getRequestCookies(actor, this.onRequestCookies);
-        window.emit(EVENTS.UPDATING_REQUEST_COOKIES, actor);
-        break;
-      case "requestPostData":
-        this.webConsoleClient.getRequestPostData(actor, this.onRequestPostData);
-        window.emit(EVENTS.UPDATING_REQUEST_POST_DATA, actor);
-        break;
-      case "securityInfo":
-        this.updateRequest(actor, {
-          securityState: networkInfo.securityInfo,
-        }).then(() => {
-          this.webConsoleClient.getSecurityInfo(actor, this.onSecurityInfo);
-          window.emit(EVENTS.UPDATING_SECURITY_INFO, actor);
-        });
-        break;
-      case "responseHeaders":
-        this.webConsoleClient.getResponseHeaders(actor, this.onResponseHeaders);
-        window.emit(EVENTS.UPDATING_RESPONSE_HEADERS, actor);
-        break;
-      case "responseCookies":
-        this.webConsoleClient.getResponseCookies(actor, this.onResponseCookies);
-        window.emit(EVENTS.UPDATING_RESPONSE_COOKIES, actor);
-        break;
-      case "responseStart":
-        this.updateRequest(actor, {
-          httpVersion: networkInfo.response.httpVersion,
-          remoteAddress: networkInfo.response.remoteAddress,
-          remotePort: networkInfo.response.remotePort,
-          status: networkInfo.response.status,
-          statusText: networkInfo.response.statusText,
-          headersSize: networkInfo.response.headersSize
-        }).then(() => {
-          window.emit(EVENTS.STARTED_RECEIVING_RESPONSE, actor);
-        });
-        break;
-      case "responseContent":
-        this.webConsoleClient.getResponseContent(actor,
-          this.onResponseContent.bind(this, {
-            contentSize: networkInfo.response.bodySize,
-            transferredSize: networkInfo.response.transferredSize,
-            mimeType: networkInfo.response.content.mimeType
-          }));
-        window.emit(EVENTS.UPDATING_RESPONSE_CONTENT, actor);
-        break;
-      case "eventTimings":
-        this.updateRequest(actor, { totalTime: networkInfo.totalTime })
-          .then(() => {
-            this.webConsoleClient.getEventTimings(actor, this.onEventTimings);
-            window.emit(EVENTS.UPDATING_EVENT_TIMINGS, actor);
-          });
-        break;
-    }
-  }
-
-  /**
-   * Handles additional information received for a "requestHeaders" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onRequestHeaders(response) {
-    this.updateRequest(response.from, {
-      requestHeaders: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_REQUEST_HEADERS, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "requestCookies" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onRequestCookies(response) {
-    this.updateRequest(response.from, {
-      requestCookies: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_REQUEST_COOKIES, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "requestPostData" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onRequestPostData(response) {
-    this.updateRequest(response.from, {
-      requestPostData: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_REQUEST_POST_DATA, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "securityInfo" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onSecurityInfo(response) {
-    this.updateRequest(response.from, {
-      securityInfo: response.securityInfo
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_SECURITY_INFO, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "responseHeaders" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onResponseHeaders(response) {
-    this.updateRequest(response.from, {
-      responseHeaders: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_RESPONSE_HEADERS, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "responseCookies" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onResponseCookies(response) {
-    this.updateRequest(response.from, {
-      responseCookies: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_RESPONSE_COOKIES, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "responseContent" packet.
-   *
-   * @param {object} data the message received from the server event.
-   * @param {object} response the message received from the server.
-   */
-  onResponseContent(data, response) {
-    let payload = Object.assign({ responseContent: response }, data);
-    this.updateRequest(response.from, payload).then(() => {
-      window.emit(EVENTS.RECEIVED_RESPONSE_CONTENT, response.from);
-    });
-  }
-
-  /**
-   * Handles additional information received for a "eventTimings" packet.
-   *
-   * @param {object} response the message received from the server.
-   */
-  onEventTimings(response) {
-    this.updateRequest(response.from, {
-      eventTimings: response
-    }).then(() => {
-      window.emit(EVENTS.RECEIVED_EVENT_TIMINGS, response.from);
-    });
-  }
 }
 
 module.exports = new FirefoxConnector();
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/src/connector/firefox-data-provider.js
@@ -0,0 +1,525 @@
+/* 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 block-scoped-var */
+
+"use strict";
+
+const { EVENTS } = require("../constants");
+const { CurlUtils } = require("devtools/client/shared/curl");
+const { fetchHeaders, formDataURI } = require("../utils/request-utils");
+
+/**
+ * This object is responsible for fetching additional HTTP
+ * data from the backend.
+ */
+class FirefoxDataProvider {
+  constructor({webConsoleClient, actions}) {
+    // Options
+    this.webConsoleClient = webConsoleClient;
+    this.actions = actions;
+
+    // Internal properties
+    this.payloadQueue = [];
+
+    // Public methods
+    this.addRequest = this.addRequest.bind(this);
+    this.updateRequest = this.updateRequest.bind(this);
+
+    // Internals
+    this.fetchImage = this.fetchImage.bind(this);
+    this.fetchRequestHeaders = this.fetchRequestHeaders.bind(this);
+    this.fetchResponseHeaders = this.fetchResponseHeaders.bind(this);
+    this.fetchPostData = this.fetchPostData.bind(this);
+    this.fetchResponseCookies = this.fetchResponseCookies.bind(this);
+    this.fetchRequestCookies = this.fetchRequestCookies.bind(this);
+    this.getPayloadFromQueue = this.getPayloadFromQueue.bind(this);
+    this.isQueuePayloadReady = this.isQueuePayloadReady.bind(this);
+    this.pushPayloadToQueue = this.pushPayloadToQueue.bind(this);
+    this.getLongString = this.getLongString.bind(this);
+    this.getNetworkRequest = this.getNetworkRequest.bind(this);
+
+    // Event handlers
+    this.onNetworkEvent = this.onNetworkEvent.bind(this);
+    this.onNetworkEventUpdate = this.onNetworkEventUpdate.bind(this);
+    this.onRequestHeaders = this.onRequestHeaders.bind(this);
+    this.onRequestCookies = this.onRequestCookies.bind(this);
+    this.onRequestPostData = this.onRequestPostData.bind(this);
+    this.onSecurityInfo = this.onSecurityInfo.bind(this);
+    this.onResponseHeaders = this.onResponseHeaders.bind(this);
+    this.onResponseCookies = this.onResponseCookies.bind(this);
+    this.onResponseContent = this.onResponseContent.bind(this);
+    this.onEventTimings = this.onEventTimings.bind(this);
+  }
+
+  /**
+   * Add a new network request to application state.
+   *
+   * @param {string} id request id
+   * @param {object} data data payload will be added to application state
+   */
+  async addRequest(id, data) {
+    let {
+      method,
+      url,
+      isXHR,
+      cause,
+      startedDateTime,
+      fromCache,
+      fromServiceWorker,
+    } = data;
+
+    if (this.actions.addRequest) {
+      await this.actions.addRequest(id, {
+        // Convert the received date/time string to a unix timestamp.
+        startedMillis: Date.parse(startedDateTime),
+        method,
+        url,
+        isXHR,
+        cause,
+        fromCache,
+        fromServiceWorker},
+        true,
+      );
+    }
+
+    emit(EVENTS.REQUEST_ADDED, id);
+  }
+
+  /**
+   * Update a network request if it already exists in application state.
+   *
+   * @param {string} id request id
+   * @param {object} data data payload will be updated to application state
+   */
+  async updateRequest(id, data) {
+    let {
+      mimeType,
+      responseContent,
+      responseCookies,
+      responseHeaders,
+      requestCookies,
+      requestHeaders,
+      requestPostData,
+    } = data;
+
+    // fetch request detail contents in parallel
+    let [
+      imageObj,
+      requestHeadersObj,
+      responseHeadersObj,
+      postDataObj,
+      requestCookiesObj,
+      responseCookiesObj,
+    ] = await Promise.all([
+      this.fetchImage(mimeType, responseContent),
+      this.fetchRequestHeaders(requestHeaders),
+      this.fetchResponseHeaders(responseHeaders),
+      this.fetchPostData(requestPostData),
+      this.fetchRequestCookies(requestCookies),
+      this.fetchResponseCookies(responseCookies),
+    ]);
+
+    let payload = Object.assign({},
+      data,
+      imageObj,
+      requestHeadersObj,
+      responseHeadersObj,
+      postDataObj,
+      requestCookiesObj,
+      responseCookiesObj
+    );
+
+    this.pushPayloadToQueue(id, payload);
+
+    if (this.actions.updateRequest && this.isQueuePayloadReady(id)) {
+      await this.actions.updateRequest(id, this.getPayloadFromQueue(id).payload, true);
+    }
+  }
+
+  async fetchImage(mimeType, responseContent) {
+    let payload = {};
+    if (mimeType && responseContent && responseContent.content) {
+      let { encoding, text } = responseContent.content;
+      let response = await this.getLongString(text);
+
+      if (mimeType.includes("image/")) {
+        payload.responseContentDataUri = formDataURI(mimeType, encoding, response);
+      }
+
+      responseContent.content.text = response;
+      payload.responseContent = responseContent;
+    }
+    return payload;
+  }
+
+  async fetchRequestHeaders(requestHeaders) {
+    let payload = {};
+    if (requestHeaders && requestHeaders.headers && requestHeaders.headers.length) {
+      let headers = await fetchHeaders(requestHeaders, this.getLongString);
+      if (headers) {
+        payload.requestHeaders = headers;
+      }
+    }
+    return payload;
+  }
+
+  async fetchResponseHeaders(responseHeaders) {
+    let payload = {};
+    if (responseHeaders && responseHeaders.headers && responseHeaders.headers.length) {
+      let headers = await fetchHeaders(responseHeaders, this.getLongString);
+      if (headers) {
+        payload.responseHeaders = headers;
+      }
+    }
+    return payload;
+  }
+
+  async fetchPostData(requestPostData) {
+    let payload = {};
+    if (requestPostData && requestPostData.postData) {
+      let { text } = requestPostData.postData;
+      let postData = await this.getLongString(text);
+      const headers = CurlUtils.getHeadersFromMultipartText(postData);
+
+      // Calculate total header size and don't forget to include
+      // two new-line characters at the end.
+      const headersSize = headers.reduce((acc, { name, value }) => {
+        return acc + name.length + value.length + 2;
+      }, 0);
+
+      requestPostData.postData.text = postData;
+      payload.requestPostData = Object.assign({}, requestPostData);
+      payload.requestHeadersFromUploadStream = { headers, headersSize };
+    }
+    return payload;
+  }
+
+  async fetchResponseCookies(responseCookies) {
+    let payload = {};
+    if (responseCookies) {
+      let resCookies = [];
+      // response store cookies in responseCookies or responseCookies.cookies
+      let cookies = responseCookies.cookies ?
+        responseCookies.cookies : responseCookies;
+      // make sure cookies is iterable
+      if (typeof cookies[Symbol.iterator] === "function") {
+        for (let cookie of cookies) {
+          resCookies.push(Object.assign({}, cookie, {
+            value: await this.getLongString(cookie.value),
+          }));
+        }
+        if (resCookies.length) {
+          payload.responseCookies = resCookies;
+        }
+      }
+    }
+    return payload;
+  }
+
+  async fetchRequestCookies(requestCookies) {
+    let payload = {};
+    if (requestCookies) {
+      let reqCookies = [];
+      // request store cookies in requestCookies or requestCookies.cookies
+      let cookies = requestCookies.cookies ?
+        requestCookies.cookies : requestCookies;
+      // make sure cookies is iterable
+      if (typeof cookies[Symbol.iterator] === "function") {
+        for (let cookie of cookies) {
+          reqCookies.push(Object.assign({}, cookie, {
+            value: await this.getLongString(cookie.value),
+          }));
+        }
+        if (reqCookies.length) {
+          payload.requestCookies = reqCookies;
+        }
+      }
+    }
+    return payload;
+  }
+
+  /**
+   * Access a payload item from payload queue.
+   *
+   * @param {string} id request id
+   * @return {boolean} return a queued payload item from queue.
+   */
+  getPayloadFromQueue(id) {
+    return this.payloadQueue.find((item) => item.id === id);
+  }
+
+  /**
+   * Return true if payload is ready (all data fetched from the backend)
+   *
+   * @param {string} id request id
+   * @return {boolean} return whether a specific networkEvent has been updated completely.
+   */
+  isQueuePayloadReady(id) {
+    let queuedPayload = this.getPayloadFromQueue(id);
+
+    // TODO we should find a better solution since it might happen
+    // that eventTimings is not the last update.
+    return queuedPayload && queuedPayload.payload.eventTimings;
+  }
+
+  /**
+   * Push a request payload into a queue if request doesn't exist. Otherwise update the
+   * request itself.
+   *
+   * @param {string} id request id
+   * @param {object} payload request data payload
+   */
+  pushPayloadToQueue(id, payload) {
+    let queuedPayload = this.getPayloadFromQueue(id);
+    if (!queuedPayload) {
+      this.payloadQueue.push({ id, payload });
+    } else {
+      // Merge upcoming networkEventUpdate payload into existing one
+      queuedPayload.payload = Object.assign({}, queuedPayload.payload, payload);
+    }
+  }
+
+  /**
+   * Fetches the network information packet from actor server
+   *
+   * @param {string} id request id
+   * @return {object} networkInfo data packet
+   */
+  getNetworkRequest(id) {
+    return this.webConsoleClient.getNetworkRequest(id);
+  }
+
+  /**
+   * Fetches the full text of a LongString.
+   *
+   * @param {object|string} stringGrip
+   *        The long string grip containing the corresponding actor.
+   *        If you pass in a plain string (by accident or because you're lazy),
+   *        then a promise of the same string is simply returned.
+   * @return {object}
+   *         A promise that is resolved when the full string contents
+   *         are available, or rejected if something goes wrong.
+   */
+  getLongString(stringGrip) {
+    return this.webConsoleClient.getString(stringGrip);
+  }
+
+  /**
+   * The "networkEvent" message type handler.
+   *
+   * @param {string} type message type
+   * @param {object} networkInfo network request information
+   */
+  onNetworkEvent(type, networkInfo) {
+    let {
+      actor,
+      cause,
+      fromCache,
+      fromServiceWorker,
+      isXHR,
+      request: {
+        method,
+        url,
+      },
+      startedDateTime,
+    } = networkInfo;
+
+    this.addRequest(actor, {
+      cause,
+      fromCache,
+      fromServiceWorker,
+      isXHR,
+      method,
+      startedDateTime,
+      url,
+    });
+
+    emit(EVENTS.NETWORK_EVENT, actor);
+  }
+
+  /**
+   * The "networkEventUpdate" message type handler.
+   *
+   * @param {string} type message type
+   * @param {object} packet the message received from the server.
+   * @param {object} networkInfo the network request information.
+   */
+  onNetworkEventUpdate(type, { packet, networkInfo }) {
+    let { actor } = networkInfo;
+
+    switch (packet.updateType) {
+      case "requestHeaders":
+        this.webConsoleClient.getRequestHeaders(actor, this.onRequestHeaders);
+        emit(EVENTS.UPDATING_REQUEST_HEADERS, actor);
+        break;
+      case "requestCookies":
+        this.webConsoleClient.getRequestCookies(actor, this.onRequestCookies);
+        emit(EVENTS.UPDATING_REQUEST_COOKIES, actor);
+        break;
+      case "requestPostData":
+        this.webConsoleClient.getRequestPostData(actor, this.onRequestPostData);
+        emit(EVENTS.UPDATING_REQUEST_POST_DATA, actor);
+        break;
+      case "securityInfo":
+        this.updateRequest(actor, {
+          securityState: networkInfo.securityInfo,
+        }).then(() => {
+          this.webConsoleClient.getSecurityInfo(actor, this.onSecurityInfo);
+          emit(EVENTS.UPDATING_SECURITY_INFO, actor);
+        });
+        break;
+      case "responseHeaders":
+        this.webConsoleClient.getResponseHeaders(actor, this.onResponseHeaders);
+        emit(EVENTS.UPDATING_RESPONSE_HEADERS, actor);
+        break;
+      case "responseCookies":
+        this.webConsoleClient.getResponseCookies(actor, this.onResponseCookies);
+        emit(EVENTS.UPDATING_RESPONSE_COOKIES, actor);
+        break;
+      case "responseStart":
+        this.updateRequest(actor, {
+          httpVersion: networkInfo.response.httpVersion,
+          remoteAddress: networkInfo.response.remoteAddress,
+          remotePort: networkInfo.response.remotePort,
+          status: networkInfo.response.status,
+          statusText: networkInfo.response.statusText,
+          headersSize: networkInfo.response.headersSize
+        }).then(() => {
+          emit(EVENTS.STARTED_RECEIVING_RESPONSE, actor);
+        });
+        break;
+      case "responseContent":
+        this.webConsoleClient.getResponseContent(actor,
+          this.onResponseContent.bind(this, {
+            contentSize: networkInfo.response.bodySize,
+            transferredSize: networkInfo.response.transferredSize,
+            mimeType: networkInfo.response.content.mimeType
+          }));
+        emit(EVENTS.UPDATING_RESPONSE_CONTENT, actor);
+        break;
+      case "eventTimings":
+        this.updateRequest(actor, { totalTime: networkInfo.totalTime })
+          .then(() => {
+            this.webConsoleClient.getEventTimings(actor, this.onEventTimings);
+            emit(EVENTS.UPDATING_EVENT_TIMINGS, actor);
+          });
+        break;
+    }
+  }
+
+  /**
+   * Handles additional information received for a "requestHeaders" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onRequestHeaders(response) {
+    this.updateRequest(response.from, {
+      requestHeaders: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_REQUEST_HEADERS, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "requestCookies" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onRequestCookies(response) {
+    this.updateRequest(response.from, {
+      requestCookies: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_REQUEST_COOKIES, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "requestPostData" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onRequestPostData(response) {
+    this.updateRequest(response.from, {
+      requestPostData: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_REQUEST_POST_DATA, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "securityInfo" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onSecurityInfo(response) {
+    this.updateRequest(response.from, {
+      securityInfo: response.securityInfo
+    }).then(() => {
+      emit(EVENTS.RECEIVED_SECURITY_INFO, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "responseHeaders" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onResponseHeaders(response) {
+    this.updateRequest(response.from, {
+      responseHeaders: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_RESPONSE_HEADERS, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "responseCookies" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onResponseCookies(response) {
+    this.updateRequest(response.from, {
+      responseCookies: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_RESPONSE_COOKIES, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "responseContent" packet.
+   *
+   * @param {object} data the message received from the server event.
+   * @param {object} response the message received from the server.
+   */
+  onResponseContent(data, response) {
+    let payload = Object.assign({ responseContent: response }, data);
+    this.updateRequest(response.from, payload).then(() => {
+      emit(EVENTS.RECEIVED_RESPONSE_CONTENT, response.from);
+    });
+  }
+
+  /**
+   * Handles additional information received for a "eventTimings" packet.
+   *
+   * @param {object} response the message received from the server.
+   */
+  onEventTimings(response) {
+    this.updateRequest(response.from, {
+      eventTimings: response
+    }).then(() => {
+      emit(EVENTS.RECEIVED_EVENT_TIMINGS, response.from);
+    });
+  }
+}
+
+/**
+ * Guard 'emit' to avoid exception in non-window environment.
+ */
+function emit(type, data) {
+  if (typeof window != "undefined") {
+    window.emit(type, data);
+  }
+}
+
+module.exports = FirefoxDataProvider;
--- a/devtools/client/netmonitor/src/connector/moz.build
+++ b/devtools/client/netmonitor/src/connector/moz.build
@@ -1,8 +1,9 @@
 # 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(
     'firefox-connector.js',
+    'firefox-data-provider.js',
     'index.js',
 )
--- a/devtools/client/netmonitor/src/constants.js
+++ b/devtools/client/netmonitor/src/constants.js
@@ -89,16 +89,54 @@ const EVENTS = {
   STARTED_RECEIVING_RESPONSE: "NetMonitor:NetworkEventUpdating:ResponseStart",
   UPDATING_RESPONSE_CONTENT: "NetMonitor:NetworkEventUpdating:ResponseContent",
   RECEIVED_RESPONSE_CONTENT: "NetMonitor:NetworkEventUpdated:ResponseContent",
 
   // Fired once the connection is established
   CONNECTED: "connected",
 };
 
+const UPDATE_PROPS = [
+  "method",
+  "url",
+  "remotePort",
+  "remoteAddress",
+  "status",
+  "statusText",
+  "httpVersion",
+  "securityState",
+  "securityInfo",
+  "mimeType",
+  "contentSize",
+  "transferredSize",
+  "totalTime",
+  "eventTimings",
+  "headersSize",
+  "customQueryValue",
+  "requestHeaders",
+  "requestHeadersFromUploadStream",
+  "requestCookies",
+  "requestPostData",
+  "responseHeaders",
+  "responseCookies",
+  "responseContent",
+  "responseContentDataUri",
+  "formDataSections",
+];
+
+const PANELS = {
+  COOKIES: "cookies",
+  HEADERS: "headers",
+  PARAMS: "params",
+  RESPONSE: "response",
+  SECURITY: "security",
+  STACK_TRACE: "stack-trace",
+  TIMINGS: "timings",
+};
+
 const RESPONSE_HEADERS = [
   "Cache-Control",
   "Connection",
   "Content-Encoding",
   "Content-Length",
   "ETag",
   "Keep-Alive",
   "Last-Modified",
@@ -239,17 +277,19 @@ const REQUESTS_WATERFALL = {
   // Reserve extra space for rendering waterfall time label
   LABEL_WIDTH: 50, // px
 };
 
 const general = {
   ACTIVITY_TYPE,
   EVENTS,
   FILTER_SEARCH_DELAY: 200,
+  UPDATE_PROPS,
   HEADERS,
   RESPONSE_HEADERS,
   FILTER_FLAGS,
   SOURCE_EDITOR_SYNTAX_HIGHLIGHT_MAX_SIZE: 51200, // 50 KB in bytes
   REQUESTS_WATERFALL,
+  PANELS,
 };
 
 // flatten constants
 module.exports = Object.assign({}, general, actionTypes);
--- a/devtools/client/netmonitor/src/reducers/requests.js
+++ b/devtools/client/netmonitor/src/reducers/requests.js
@@ -10,16 +10,17 @@ const {
   ADD_REQUEST,
   CLEAR_REQUESTS,
   CLONE_SELECTED_REQUEST,
   OPEN_NETWORK_DETAILS,
   REMOVE_SELECTED_CUSTOM_REQUEST,
   SELECT_REQUEST,
   SEND_CUSTOM_REQUEST,
   UPDATE_REQUEST,
+  UPDATE_PROPS,
 } = require("../constants");
 
 const Request = I.Record({
   id: null,
   // Set to true in case of a request that's being edited as part of "edit and resend"
   isCustom: false,
   // Request properties - at the beginning, they are unknown and are gradually filled in
   startedMillis: undefined,
@@ -63,44 +64,16 @@ const Requests = I.Record({
   // Selection state
   selectedId: null,
   preselectedId: null,
   // Auxiliary fields to hold requests stats
   firstStartedMillis: +Infinity,
   lastEndedMillis: -Infinity,
 });
 
-const UPDATE_PROPS = [
-  "method",
-  "url",
-  "remotePort",
-  "remoteAddress",
-  "status",
-  "statusText",
-  "httpVersion",
-  "securityState",
-  "securityInfo",
-  "mimeType",
-  "contentSize",
-  "transferredSize",
-  "totalTime",
-  "eventTimings",
-  "headersSize",
-  "customQueryValue",
-  "requestHeaders",
-  "requestHeadersFromUploadStream",
-  "requestCookies",
-  "requestPostData",
-  "responseHeaders",
-  "responseCookies",
-  "responseContent",
-  "responseContentDataUri",
-  "formDataSections",
-];
-
 /**
  * Remove the currently selected custom request.
  */
 function closeCustomRequest(state) {
   let { requests, selectedId } = state;
 
   if (!selectedId) {
     return state;
--- a/devtools/client/netmonitor/src/reducers/ui.js
+++ b/devtools/client/netmonitor/src/reducers/ui.js
@@ -14,16 +14,17 @@ const {
   REMOVE_SELECTED_CUSTOM_REQUEST,
   RESET_COLUMNS,
   RESPONSE_HEADERS,
   SELECT_DETAILS_PANEL_TAB,
   SEND_CUSTOM_REQUEST,
   SELECT_REQUEST,
   TOGGLE_COLUMN,
   WATERFALL_RESIZE,
+  PANELS,
 } = require("../constants");
 
 const cols = {
   status: true,
   method: true,
   file: true,
   protocol: false,
   scheme: false,
@@ -46,17 +47,17 @@ const Columns = I.Record(
   Object.assign(
     cols,
     RESPONSE_HEADERS.reduce((acc, header) => Object.assign(acc, { [header]: false }), {})
   )
 );
 
 const UI = I.Record({
   columns: new Columns(),
-  detailsPanelSelectedTab: "headers",
+  detailsPanelSelectedTab: PANELS.HEADERS,
   networkDetailsOpen: false,
   browserCacheDisabled: Services.prefs.getBoolPref("devtools.cache.disabled"),
   statisticsOpen: false,
   waterfallWidth: null,
 });
 
 function resetColumns(state) {
   return state.set("columns", new Columns());
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -844,24 +844,53 @@ a.learn-more-link.webconsole-learn-more-
 .message.warn > .icon::before {
   background-position: -24px -36px;
 }
 
 .message.info > .icon::before {
   background-position: -36px -36px;
 }
 
+/* Network Messages */
+
 .message.network .method {
   margin-inline-end: 5px;
 }
 
+.network.message .network-info {
+  display: none;
+  margin-top: 8px;
+  border: solid 1px var(--theme-splitter-color);
+}
+
+.network.message.open .network-info {
+  display: block;
+}
+
+.network.message .network-info .panels {
+  max-height: 250px;
+  min-height: 100px;
+}
+
+/* Hide 'Edit And Resend' button since the feature isn't
+   supported in the Console panel. */
+.network.message #headers-panel .edit-and-resend-button {
+  display: none;
+}
+
+.network.message #response-panel .treeTable {
+  overflow-y: hidden;
+}
+
 .network .message-flex-body > .message-body {
   display: flex;
 }
 
+/* Output Wrapper */
+
 .webconsole-output-wrapper .message .indent {
   display: inline-block;
   border-inline-end: solid 1px var(--theme-splitter-color);
 }
 .webconsole-output-wrapper .message .indent[data-indent="0"] {
   border-inline-end: none;
 }
 
--- a/devtools/client/webconsole/new-console-output/actions/messages.js
+++ b/devtools/client/webconsole/new-console-output/actions/messages.js
@@ -10,16 +10,17 @@ const {
   prepareMessage
 } = require("devtools/client/webconsole/new-console-output/utils/messages");
 const { IdGenerator } = require("devtools/client/webconsole/new-console-output/utils/id-generator");
 const { batchActions } = require("devtools/client/shared/redux/middleware/debounce");
 
 const {
   MESSAGE_ADD,
   NETWORK_MESSAGE_UPDATE,
+  NETWORK_UPDATE_REQUEST,
   MESSAGES_CLEAR,
   MESSAGE_OPEN,
   MESSAGE_CLOSE,
   MESSAGE_TYPE,
   MESSAGE_TABLE_RECEIVE,
   MESSAGE_OBJECT_PROPERTIES_RECEIVE,
   MESSAGE_OBJECT_ENTRIES_RECEIVE,
 } = require("../constants");
@@ -89,26 +90,35 @@ function messageTableDataGet(id, client,
 function messageTableDataReceive(id, data) {
   return {
     type: MESSAGE_TABLE_RECEIVE,
     id,
     data
   };
 }
 
-function networkMessageUpdate(packet, idGenerator = null) {
+function networkMessageUpdate(packet, idGenerator = null, response) {
   if (idGenerator == null) {
     idGenerator = defaultIdGenerator;
   }
 
   let message = prepareMessage(packet, idGenerator);
 
   return {
     type: NETWORK_MESSAGE_UPDATE,
     message,
+    response,
+  };
+}
+
+function networkUpdateRequest(id, data) {
+  return {
+    type: NETWORK_UPDATE_REQUEST,
+    id,
+    data,
   };
 }
 
 /**
  * This action is used to load the properties of a grip passed as an argument,
  * for a given message. The action then dispatch the messageObjectPropertiesReceive
  * action with the loaded properties.
  * This action is mainly called by the ObjectInspector component when the user expands
@@ -174,16 +184,17 @@ function messageObjectEntriesReceive(id,
 
 module.exports = {
   messageAdd,
   messagesClear,
   messageOpen,
   messageClose,
   messageTableDataGet,
   networkMessageUpdate,
+  networkUpdateRequest,
   messageObjectPropertiesLoad,
   messageObjectEntriesLoad,
   // for test purpose only.
   messageTableDataReceive,
   messageObjectPropertiesReceive,
   messageObjectEntriesReceive,
 };
 
--- a/devtools/client/webconsole/new-console-output/components/console-output.js
+++ b/devtools/client/webconsole/new-console-output/components/console-output.js
@@ -62,17 +62,17 @@ const ConsoleOutput = createClass({
     if (!outputNode || !outputNode.lastChild) {
       return;
     }
 
     const lastChild = outputNode.lastChild;
     const visibleMessagesDelta =
       nextProps.visibleMessages.length - this.props.visibleMessages.length;
     const messagesDelta =
-      nextProps.messages.length - this.props.messages.length;
+      nextProps.messages.size - this.props.messages.size;
 
     // We need to scroll to the bottom if:
     // - the number of messages displayed changed
     //   and we are already scrolled to the bottom
     // - the number of messages in the store changed
     //   and the new message is an evaluation result.
     this.shouldScrollBottom =
       (messagesDelta > 0 && nextProps.messages.last().type === MESSAGE_TYPE.RESULT) ||
--- a/devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
+++ b/devtools/client/webconsole/new-console-output/components/message-types/network-event-message.js
@@ -9,35 +9,53 @@
 // React & Redux
 const {
   createFactory,
   DOM: dom,
   PropTypes
 } = require("devtools/client/shared/vendor/react");
 const Message = createFactory(require("devtools/client/webconsole/new-console-output/components/message"));
 const { l10n } = require("devtools/client/webconsole/new-console-output/utils/messages");
+const TabboxPanel = createFactory(require("devtools/client/netmonitor/src/components/tabbox-panel"));
+const { PANELS } = require("devtools/client/netmonitor/src/constants");
 
 NetworkEventMessage.displayName = "NetworkEventMessage";
 
 NetworkEventMessage.propTypes = {
   message: PropTypes.object.isRequired,
   serviceContainer: PropTypes.shape({
     openNetworkPanel: PropTypes.func.isRequired,
   }),
   timestampsVisible: PropTypes.bool.isRequired,
   networkMessageUpdate: PropTypes.object.isRequired,
 };
 
+/**
+ * This component is responsible for rendering network messages
+ * in the Console panel.
+ *
+ * Network logs are expandable and the user can inspect it inline
+ * within the Console panel (no need to switch to the Network panel).
+ *
+ * HTTP details are rendered using `TabboxPanel` component used to
+ * render contents of the side bar in the Network panel.
+ *
+ * All HTTP details data are fetched from the backend on-demand
+ * when the user is expanding network log for the first time.
+ */
 function NetworkEventMessage({
   message = {},
   serviceContainer,
   timestampsVisible,
   networkMessageUpdate = {},
+  dispatch,
+  open,
 }) {
   const {
+    id,
     actor,
     indent,
     source,
     type,
     level,
     request,
     isXHR,
     timeStamp,
@@ -72,21 +90,38 @@ function NetworkEventMessage({
   const url = dom.a({ className: "url", title: request.url, onClick: openNetworkMonitor },
     request.url.replace(/\?.+/, ""));
   const statusBody = statusInfo
     ? dom.a({ className: "status", onClick: openNetworkMonitor }, statusInfo)
     : null;
 
   const messageBody = [method, xhr, url, statusBody];
 
+  // Only render the attachment if the network-event is
+  // actually opened (performance optimization).
+  const attachment = open && dom.div({className: "network-info devtools-monospace"},
+    TabboxPanel({
+      activeTabId: PANELS.HEADERS,
+      request: networkMessageUpdate,
+      sourceMapService: serviceContainer.sourceMapService,
+      cloneSelectedRequest: () => {},
+      selectTab: (tabId) => {},
+    })
+  );
+
   return Message({
+    dispatch,
+    messageId: id,
     source,
     type,
     level,
     indent,
+    collapsible: true,
+    open,
+    attachment,
     topLevelClasses,
     timeStamp,
     messageBody,
     serviceContainer,
     request,
     timestampsVisible,
   });
 }
--- a/devtools/client/webconsole/new-console-output/constants.js
+++ b/devtools/client/webconsole/new-console-output/constants.js
@@ -7,16 +7,17 @@
 
 const actionTypes = {
   BATCH_ACTIONS: "BATCH_ACTIONS",
   MESSAGE_ADD: "MESSAGE_ADD",
   MESSAGES_CLEAR: "MESSAGES_CLEAR",
   MESSAGE_OPEN: "MESSAGE_OPEN",
   MESSAGE_CLOSE: "MESSAGE_CLOSE",
   NETWORK_MESSAGE_UPDATE: "NETWORK_MESSAGE_UPDATE",
+  NETWORK_UPDATE_REQUEST: "NETWORK_UPDATE_REQUEST",
   MESSAGE_TABLE_RECEIVE: "MESSAGE_TABLE_RECEIVE",
   MESSAGE_OBJECT_PROPERTIES_RECEIVE: "MESSAGE_OBJECT_PROPERTIES_RECEIVE",
   MESSAGE_OBJECT_ENTRIES_RECEIVE: "MESSAGE_OBJECT_ENTRIES_RECEIVE",
   REMOVED_ACTORS_CLEAR: "REMOVED_ACTORS_CLEAR",
   TIMESTAMPS_TOGGLE: "TIMESTAMPS_TOGGLE",
   FILTER_TOGGLE: "FILTER_TOGGLE",
   FILTER_TEXT_SET: "FILTER_TEXT_SET",
   FILTERS_CLEAR: "FILTERS_CLEAR",
--- a/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
+++ b/devtools/client/webconsole/new-console-output/new-console-output-wrapper.js
@@ -47,16 +47,21 @@ NewConsoleOutputWrapper.prototype = {
       }
 
       // Do not focus if a link was clicked
       let target = event.originalTarget || event.target;
       if (target.closest("a")) {
         return;
       }
 
+      // Do not focus if an input field was clicked
+      if (target.closest("input")) {
+        return;
+      }
+
       // Do not focus if something other than the output region was clicked
       if (!target.closest(".webconsole-output")) {
         return;
       }
 
       // Do not focus if something is selected
       let selection = this.document.defaultView.getSelection();
       if (selection && !selection.isCollapsed) {
@@ -214,16 +219,20 @@ NewConsoleOutputWrapper.prototype = {
     // that networkInfo.updates has all we need.
     const NUMBER_OF_NETWORK_UPDATE = 8;
     if (res.networkInfo.updates.length === NUMBER_OF_NETWORK_UPDATE) {
       batchedMessageAdd(actions.networkMessageUpdate(message));
       this.jsterm.hud.emit("network-message-updated", res);
     }
   },
 
+  dispatchRequestUpdate: function (id, data) {
+    batchedMessageAdd(actions.networkUpdateRequest(id, data));
+  },
+
   // Should be used for test purpose only.
   getStore: function () {
     return store;
   }
 };
 
 function batchedMessageAdd(action) {
   queuedActions.push(action);
--- a/devtools/client/webconsole/new-console-output/reducers/messages.js
+++ b/devtools/client/webconsole/new-console-output/reducers/messages.js
@@ -17,16 +17,20 @@ const {
   DEFAULT_FILTERS,
   FILTERS,
   MESSAGE_TYPE,
   MESSAGE_SOURCE,
 } = constants;
 const { getGripPreviewItems } = require("devtools/client/shared/components/reps/reps");
 const { getSourceNames } = require("devtools/client/shared/source-utils");
 
+const {
+  UPDATE_PROPS
+} = require("devtools/client/netmonitor/src/constants");
+
 const MessageState = Immutable.Record({
   // List of all the messages added to the console.
   messagesById: Immutable.OrderedMap(),
   // Array of the visible messages.
   visibleMessages: [],
   // Object for the filtered messages.
   filteredMessagesCount: getDefaultFiltersCounter(),
   // List of the message ids which are opened.
@@ -161,18 +165,20 @@ function messages(state = new MessageSta
           return res;
         }, [])
       });
 
     case constants.MESSAGE_OPEN:
       return state.withMutations(function (record) {
         record.set("messagesUiById", messagesUiById.push(action.id));
 
+        let currMessage = messagesById.get(action.id);
+
         // If the message is a group
-        if (isGroupType(messagesById.get(action.id).type)) {
+        if (isGroupType(currMessage.type)) {
           // We want to make its children visible
           const messagesToShow = [...messagesById].reduce((res, [id, message]) => {
             if (
               !visibleMessages.includes(message.id)
               && getParentGroups(message.groupId, groupsById).includes(action.id)
               && getMessageVisibility(
                 message,
                 record,
@@ -190,16 +196,31 @@ function messages(state = new MessageSta
           // We can then insert the messages ids right after the one of the group.
           const insertIndex = visibleMessages.indexOf(action.id) + 1;
           record.set("visibleMessages", [
             ...visibleMessages.slice(0, insertIndex),
             ...messagesToShow,
             ...visibleMessages.slice(insertIndex),
           ]);
         }
+
+        // If the current message is a network event, mark it as opened-once,
+        // so HTTP details are not fetched again the next time the user
+        // opens the log.
+        if (currMessage.source == "network") {
+          record.set("messagesById",
+            messagesById.set(
+              action.id, Object.assign({},
+                currMessage, {
+                  openedOnce: true
+                }
+              )
+            )
+          );
+        }
       });
 
     case constants.MESSAGE_CLOSE:
       return state.withMutations(function (record) {
         let messageId = action.id;
         let index = record.messagesUiById.indexOf(messageId);
         record.deleteIn(["messagesUiById", index]);
 
@@ -245,16 +266,54 @@ function messages(state = new MessageSta
     case constants.NETWORK_MESSAGE_UPDATE:
       return state.set(
         "networkMessagesUpdateById",
         Object.assign({}, networkMessagesUpdateById, {
           [action.message.id]: action.message
         })
       );
 
+    case constants.NETWORK_UPDATE_REQUEST: {
+      let request = networkMessagesUpdateById[action.id];
+      if (!request) {
+        return state;
+      }
+
+      let values = {};
+      for (let [key, value] of Object.entries(action.data)) {
+        if (UPDATE_PROPS.includes(key)) {
+          values[key] = value;
+
+          switch (key) {
+            case "securityInfo":
+              values.securityState = value.state;
+              break;
+            case "totalTime":
+              values.totalTime = request.totalTime;
+              break;
+            case "requestPostData":
+              values.requestHeadersFromUploadStream = {
+                headers: [],
+                headersSize: 0,
+              };
+              break;
+          }
+        }
+      }
+
+      let newState = state.set(
+        "networkMessagesUpdateById",
+        Object.assign({}, networkMessagesUpdateById, {
+          [action.id]: Object.assign({}, request, values)
+        })
+      );
+
+      return newState;
+    }
+
     case constants.REMOVED_ACTORS_CLEAR:
       return state.set("removedActors", []);
 
     case constants.FILTER_TOGGLE:
     case constants.FILTER_TEXT_SET:
     case constants.FILTERS_CLEAR:
     case constants.DEFAULT_FILTERS_RESET:
       return state.withMutations(function (record) {
--- a/devtools/client/webconsole/new-console-output/store.js
+++ b/devtools/client/webconsole/new-console-output/store.js
@@ -12,23 +12,34 @@ const {
   createStore
 } = require("devtools/client/shared/vendor/redux");
 const { thunk } = require("devtools/client/shared/redux/middleware/thunk");
 const {
   BATCH_ACTIONS
 } = require("devtools/client/shared/redux/middleware/debounce");
 const {
   MESSAGE_ADD,
+  MESSAGE_OPEN,
   MESSAGES_CLEAR,
   REMOVED_ACTORS_CLEAR,
+  NETWORK_MESSAGE_UPDATE,
   PREFS,
 } = require("devtools/client/webconsole/new-console-output/constants");
 const { reducers } = require("./reducers/index");
 const Services = require("Services");
+const {
+  getMessage,
+  getAllMessagesUiById,
+} = require("devtools/client/webconsole/new-console-output/selectors/messages");
+const DataProvider = require("devtools/client/netmonitor/src/connector/firefox-data-provider");
 
+/**
+ * Create and configure store for the Console panel. This is the place
+ * where various enhancers and middleware can be registered.
+ */
 function configureStore(hud, options = {}) {
   const logLimit = options.logLimit
     || Math.max(Services.prefs.getIntPref("devtools.hud.loglimit"), 1);
 
   const initialState = {
     prefs: new PrefState({ logLimit }),
     filters: new FilterState({
       error: Services.prefs.getBoolPref(PREFS.FILTER.ERROR),
@@ -43,17 +54,22 @@ function configureStore(hud, options = {
     ui: new UiState({
       filterBarVisible: Services.prefs.getBoolPref(PREFS.UI.FILTER_BAR),
     })
   };
 
   return createStore(
     createRootReducer(),
     initialState,
-    compose(applyMiddleware(thunk), enableActorReleaser(hud), enableBatching())
+    compose(
+      applyMiddleware(thunk),
+      enableActorReleaser(hud),
+      enableBatching(),
+      enableNetProvider(hud)
+    )
   );
 }
 
 function createRootReducer() {
   return function rootReducer(state, action) {
     // We want to compute the new state for all properties except "messages".
     const newState = [...Object.entries(reducers)].reduce((res, [key, reducer]) => {
       if (key !== "messages") {
@@ -121,16 +137,79 @@ function enableActorReleaser(hud) {
       return state;
     }
 
     return next(releaseActorsEnhancer, initialState, enhancer);
   };
 }
 
 /**
+ * This enhancer is responsible for fetching HTTP details data
+ * collected by the backend. The fetch happens on-demand
+ * when the user expands network log in order to inspect it.
+ *
+ * This way we don't slow down the Console logging by fetching.
+ * unnecessary data over RDP.
+ */
+function enableNetProvider(hud) {
+  let dataProvider;
+  return next => (reducer, initialState, enhancer) => {
+    function netProviderEnhancer(state, action) {
+      let proxy = hud ? hud.proxy : null;
+      if (!proxy) {
+        return reducer(state, action);
+      }
+
+      let actions = {
+        updateRequest: (id, data, batch) => {
+          proxy.dispatchRequestUpdate(id, data);
+        }
+      };
+
+      // Data provider implements async logic for fetching
+      // data from the backend. It's created the first
+      // time it's needed.
+      if (!dataProvider) {
+        dataProvider = new DataProvider({
+          actions,
+          webConsoleClient: proxy.webConsoleClient
+        });
+      }
+
+      let type = action.type;
+
+      // If network message has been opened, fetch all
+      // HTTP details from the backend.
+      if (type == MESSAGE_OPEN) {
+        let message = getMessage(state, action.id);
+        if (!message.openedOnce && message.source == "network") {
+          message.updates.forEach(updateType => {
+            dataProvider.onNetworkEventUpdate(null, {
+              packet: { updateType: updateType },
+              networkInfo: message,
+            });
+          });
+        }
+      }
+
+      // Process all incoming HTTP details packets.
+      if (type == NETWORK_MESSAGE_UPDATE) {
+        let open = getAllMessagesUiById(state).includes(action.id);
+        if (open) {
+          dataProvider.onNetworkEventUpdate(null, action.response);
+        }
+      }
+
+      return reducer(state, action);
+    }
+
+    return next(netProviderEnhancer, initialState, enhancer);
+  };
+}
+/**
  * Helper function for releasing backend actors.
  */
 function releaseActors(removedActors, proxy) {
   if (!proxy) {
     return;
   }
 
   removedActors.forEach(actor => proxy.releaseActor(actor));
--- a/devtools/client/webconsole/new-console-output/types.js
+++ b/devtools/client/webconsole/new-console-output/types.js
@@ -55,10 +55,15 @@ exports.NetworkEventMessage = function (
     request: null,
     response: null,
     source: MESSAGE_SOURCE.NETWORK,
     type: MESSAGE_TYPE.LOG,
     groupId: null,
     timeStamp: null,
     totalTime: null,
     indent: 0,
+    updates: null,
+    openedOnce: false,
+    securityState: null,
+    securityInfo: null,
+    requestHeadersFromUploadStream: null,
   }, props);
 };
--- a/devtools/client/webconsole/new-console-output/utils/messages.js
+++ b/devtools/client/webconsole/new-console-output/utils/messages.js
@@ -2,16 +2,17 @@
 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 const l10n = require("devtools/client/webconsole/webconsole-l10n");
+const { getUrlDetails } = require("devtools/client/netmonitor/src/utils/request-utils");
 
 const {
   MESSAGE_SOURCE,
   MESSAGE_TYPE,
   MESSAGE_LEVEL,
 } = require("../constants");
 const {
   ConsoleMessage,
@@ -227,16 +228,21 @@ function transformNetworkEventPacket(pac
 
   return new NetworkEventMessage({
     actor: networkEvent.actor,
     isXHR: networkEvent.isXHR,
     request: networkEvent.request,
     response: networkEvent.response,
     timeStamp: networkEvent.timeStamp,
     totalTime: networkEvent.totalTime,
+    url: networkEvent.request.url,
+    urlDetails: getUrlDetails(networkEvent.request.url),
+    method: networkEvent.request.method,
+    updates: networkEvent.updates,
+    cause: networkEvent.cause,
   });
 }
 
 function transformEvaluationResultPacket(packet) {
   let {
     exceptionMessage: messageText,
     exceptionDocURL,
     frame,
--- a/devtools/client/webconsole/panel.js
+++ b/devtools/client/webconsole/panel.js
@@ -83,17 +83,17 @@ WebConsolePanel.prototype = {
         this.hud = webConsole;
         this._isReady = true;
         this.emit("ready");
         return this;
       }, (reason) => {
         let msg = "WebConsolePanel open failed. " +
                   reason.error + ": " + reason.message;
         dump(msg + "\n");
-        console.error(msg);
+        console.error(msg, reason);
       });
   },
 
   get target() {
     return this._toolbox.target;
   },
 
   _isReady: false,
--- a/devtools/client/webconsole/webconsole-connection-proxy.js
+++ b/devtools/client/webconsole/webconsole-connection-proxy.js
@@ -238,16 +238,20 @@ WebConsoleConnectionProxy.prototype = {
 
   /**
    * Dispatch a message event on the new frontend and emit an event for tests.
    */
   dispatchMessageUpdate: function (networkInfo, response) {
     this.webConsoleFrame.newConsoleOutput.dispatchMessageUpdate(networkInfo, response);
   },
 
+  dispatchRequestUpdate: function (id, data) {
+    this.webConsoleFrame.newConsoleOutput.dispatchRequestUpdate(id, data);
+  },
+
   /**
    * The "cachedMessages" response handler.
    *
    * @private
    * @param object response
    *        The JSON response object received from the server.
    */
   _onCachedMessages: function (response) {
--- a/devtools/client/webconsole/webconsole.xhtml
+++ b/devtools/client/webconsole/webconsole.xhtml
@@ -6,16 +6,20 @@
 <html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" dir="">
 <head>
   <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
     <link rel="stylesheet" href="chrome://devtools/skin/widgets.css"/>
     <link rel="stylesheet" href="resource://devtools/client/themes/light-theme.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/webconsole.css"/>
     <link rel="stylesheet" href="chrome://devtools/skin/components-frame.css"/>
     <link rel="stylesheet" href="resource://devtools/client/shared/components/reps/reps.css"/>
+    <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/tabs.css"/>
+    <link rel="stylesheet" href="resource://devtools/client/shared/components/tabs/tabbar.css"/>
+    <link rel="stylesheet" href="chrome://devtools/content/netmonitor/src/assets/styles/netmonitor.css"/>
+
     <script src="chrome://devtools/content/shared/theme-switching.js"></script>
     <script type="application/javascript"
             src="resource://devtools/client/webconsole/new-console-output/main.js"></script>
   </head>
   <body class="theme-sidebar" role="application">
     <div id="app-wrapper" class="theme-body">
       <div id="output-container" role="document" aria-live="polite"/>
       <div id="jsterm-wrapper">