Bug 1211525 - HTTP log inspection in the Console panel; r=jlongster,helenvholmes,bgrins,linclark a=kwierso
authorJan Odvarko <odvarko@gmail.com>
Fri, 12 Feb 2016 18:25:09 +0100
changeset 291293 6ecf26c604a3f6a80e6757b9107a2deb1a689937
parent 291292 50c354c8516f0cf1ea64b216ca09f5de69c5968c
child 291294 66f61a6565710232618266a9c8099cdb85287701
push id19656
push usergwagner@mozilla.com
push dateMon, 04 Apr 2016 13:43:23 +0000
treeherderb2g-inbound@e99061fde28a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjlongster, helenvholmes, bgrins, linclark, kwierso
bugs1211525
milestone48.0a1
Bug 1211525 - HTTP log inspection in the Console panel; r=jlongster,helenvholmes,bgrins,linclark a=kwierso MozReview-Commit-ID: AA1xxjJtCPQ
devtools/client/jsonview/css/tabs.css
devtools/client/locales/en-US/netmonitor.properties
devtools/client/shared/components/reps/moz.build
devtools/client/shared/components/reps/reps.css
devtools/client/shared/components/reps/string.js
devtools/client/shared/components/reps/url.js
devtools/client/themes/webconsole.css
devtools/client/webconsole/moz.build
devtools/client/webconsole/net/.eslintrc
devtools/client/webconsole/net/components/cookies-tab.js
devtools/client/webconsole/net/components/headers-tab.js
devtools/client/webconsole/net/components/moz.build
devtools/client/webconsole/net/components/net-info-body.css
devtools/client/webconsole/net/components/net-info-body.js
devtools/client/webconsole/net/components/net-info-group-list.js
devtools/client/webconsole/net/components/net-info-group.css
devtools/client/webconsole/net/components/net-info-group.js
devtools/client/webconsole/net/components/net-info-params.css
devtools/client/webconsole/net/components/net-info-params.js
devtools/client/webconsole/net/components/params-tab.js
devtools/client/webconsole/net/components/post-tab.js
devtools/client/webconsole/net/components/response-tab.css
devtools/client/webconsole/net/components/response-tab.js
devtools/client/webconsole/net/components/size-limit.css
devtools/client/webconsole/net/components/size-limit.js
devtools/client/webconsole/net/components/spinner.js
devtools/client/webconsole/net/data-provider.js
devtools/client/webconsole/net/main.js
devtools/client/webconsole/net/moz.build
devtools/client/webconsole/net/net-request.css
devtools/client/webconsole/net/net-request.js
devtools/client/webconsole/net/utils/events.js
devtools/client/webconsole/net/utils/json.js
devtools/client/webconsole/net/utils/moz.build
devtools/client/webconsole/net/utils/net.js
devtools/client/webconsole/webconsole.js
devtools/client/webconsole/webconsole.xul
devtools/docs/http-inspector.md
--- a/devtools/client/jsonview/css/tabs.css
+++ b/devtools/client/jsonview/css/tabs.css
@@ -72,18 +72,17 @@
 
 .theme-firebug .tabs .tabs-menu-item a {
   padding: 5px 8px 4px 8px;;
   font-weight: bold;
   color: #565656;
   border-radius: 4px 4px 0 0;
 }
 
-.theme-firebug .tabs .tabs-menu-item.is-active a,
-.theme-firebug .tabs .tabs-menu-item.is-active a:focus {
+.theme-firebug .tabs .tabs-menu-item.is-active a {
   background-color: rgb(247, 251, 254);
   border: 1px solid rgb(170, 188, 207);
   border-bottom-color: transparent;
 }
 
 .theme-firebug .tabs .tabs-menu-item:hover a {
   border: 1px solid #C8C8C8;
   border-bottom: 1px solid transparent;
--- a/devtools/client/locales/en-US/netmonitor.properties
+++ b/devtools/client/locales/en-US/netmonitor.properties
@@ -64,17 +64,17 @@ netmonitor.security.disabled=Disabled
 # LOCALIZATION NOTE (netmonitor.security.hostHeader):
 # This string is used as a header for section containing security information
 # related to the remote host. %S is replaced with the domain name of the remote
 # host. For example: Host example.com
 netmonitor.security.hostHeader=Host %S:
 
 # LOCALIZATION NOTE (netmonitor.security.notAvailable):
 # This string is used to indicate that a certain piece of information is not
-# available to be displayd. For example a certificate that has no organization
+# available to be displayed. For example a certificate that has no organization
 # defined:
 #   Organization: <Not Available>
 netmonitor.security.notAvailable=<Not Available>
 
 # LOCALIZATION NOTE (collapseDetailsPane): This is the tooltip for the button
 # that collapses the network details pane in the UI.
 collapseDetailsPane=Hide request details
 
@@ -253,8 +253,58 @@ charts.totalSeconds=Time: #1 second;Time
 
 # LOCALIZATION NOTE (charts.totalCached): This is the label displayed
 # in the performance analysis view for total cached responses.
 charts.totalCached=Cached responses: %S
 
 # LOCALIZATION NOTE (charts.totalCount): This is the label displayed
 # in the performance analysis view for total requests.
 charts.totalCount=Total requests: %S
+
+# LOCALIZATION NOTE (netRequest.headers): A label used for Headers tab
+# This tab displays list of HTTP headers
+netRequest.headers=Headers
+
+# LOCALIZATION NOTE (netRequest.response): A label used for Response tab
+# This tab displays HTTP response body
+netRequest.response=Response
+
+# LOCALIZATION NOTE (netRequest.rawData): A label used for a section
+# in Response tab. This section displays raw response body as it's
+# been received from the backend (debugger server)
+netRequest.rawData=Raw Data
+
+# LOCALIZATION NOTE (netRequest.xml): A label used for a section
+# in Response tab. This section displays parsed XML response body.
+netRequest.xml=XML
+
+# LOCALIZATION NOTE (netRequest.image): A label used for a section
+# in Response tab. This section displays images returned in response body.
+netRequest.image=Image
+
+# LOCALIZATION NOTE (netRequest.sizeLimitMessage): A label used
+# in Response and Post tabs in case the body is bigger than given limit.
+# It allows the user to click and fetch more from the backend.
+# The {{link}} will be replace at run-time by an active link.
+# String with ID 'netRequest.sizeLimitMessageLink' will be used as text
+# for this link.
+netRequest.sizeLimitMessage=Size limit has been reached. Click {{link}} to load more.
+netRequest.sizeLimitMessageLink=here
+
+# LOCALIZATION NOTE (netRequest.responseBodyDiscarded): A label used
+# in Response tab if the response body is not available.
+netRequest.responseBodyDiscarded=Response body was not stored.
+
+# LOCALIZATION NOTE (netRequest.requestBodyDiscarded): A label used
+# in Post tab if the post body is not available.
+netRequest.requestBodyDiscarded=Request POST body was not stored.
+
+# LOCALIZATION NOTE (netRequest.post): A label used for Post tab
+# This tab displays HTTP post body
+netRequest.post=POST
+
+# LOCALIZATION NOTE (netRequest.cookies): A label used for Cookies tab
+# This tab displays request and response cookies.
+netRequest.cookies=Cookies
+
+# LOCALIZATION NOTE (netRequest.params): A label used for URL parameters tab
+# This tab displays data parsed from URL query string.
+netRequest.params=Params
--- a/devtools/client/shared/components/reps/moz.build
+++ b/devtools/client/shared/components/reps/moz.build
@@ -12,9 +12,10 @@ DevToolsModules(
     'object-box.js',
     'object-link.js',
     'object.js',
     'rep-utils.js',
     'rep.js',
     'reps.css',
     'string.js',
     'undefined.js',
+    'url.js',
 )
--- a/devtools/client/shared/components/reps/reps.css
+++ b/devtools/client/shared/components/reps/reps.css
@@ -41,16 +41,21 @@
 }
 
 .objectLink-function,
 .objectBox-stackTrace,
 .objectLink-profile {
   color: DarkGreen;
 }
 
+.objectLink-Location {
+  font-style: italic;
+  color: #555555;
+}
+
 .objectBox-null,
 .objectBox-undefined,
 .objectBox-hint,
 .logRowHint {
   font-style: italic;
   color: #787878;
 }
 
--- a/devtools/client/shared/components/reps/string.js
+++ b/devtools/client/shared/components/reps/string.js
@@ -45,17 +45,17 @@ define(function(require, exports, module
   }
 
   function cropMultipleLines(text, limit) {
     return escapeNewLines(cropString(text, limit));
   }
 
   function cropString(text, limit, alternativeText) {
     if (!alternativeText) {
-      alternativeText = "...";
+      alternativeText = "\u2026";
     }
 
     // Make sure it's a string.
     text = text + "";
 
     // Use default limit if necessary.
     if (!limit) {
       limit = 50;
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/reps/url.js
@@ -0,0 +1,38 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* global URLSearchParams, URL */
+
+"use strict";
+
+// Make this available to both AMD and CJS environments
+define(function(require, exports, module) {
+  function parseURLParams(url) {
+    url = new URL(url);
+    return parseURLEncodedText(url.searchParams);
+  }
+
+  function parseURLEncodedText(text) {
+    let params = [];
+
+    // In case the text is empty just return the empty parameters
+    if (text == "") {
+      return params;
+    }
+
+    let searchParams = new URLSearchParams(text);
+    let entries = [...searchParams.entries()];
+    return entries.map(entry => {
+      return {
+        name: entry[0],
+        value: entry[1]
+      };
+    });
+  }
+
+  // Exports from this module
+  exports.parseURLParams = parseURLParams;
+  exports.parseURLEncodedText = parseURLEncodedText;
+});
--- a/devtools/client/themes/webconsole.css
+++ b/devtools/client/themes/webconsole.css
@@ -204,25 +204,26 @@ a {
 .theme-selected .console-string,
 .theme-selected .cm-number,
 .theme-selected .cm-variable,
 .theme-selected .kind-ArrayLike {
   color: #f5f7fa !important; /* Selection Text Color */
 }
 
 .message[category=network] > .indent {
-  -moz-border-end: solid #000 6px;
+  -moz-border-end: solid var(--theme-body-color-alt) 6px;
 }
 
 .message[category=network][severity=error] > .icon::before {
   background-position: -12px 0;
 }
 
 .message[category=network] > .message-body {
   display: flex;
+  flex-wrap: wrap;
 }
 
 .message[category=network] .method {
   flex: none;
 }
 
 .message[category=network]:not(.navigation-marker) .url {
   flex: 1 1 auto;
@@ -251,17 +252,18 @@ a {
 .message[category=network] .xhr {
   background-color: var(--theme-body-color-alt);
   color: var(--theme-body-background);
   border-radius: 3px;
   font-weight: bold;
   font-size: 10px;
   padding: 2px;
   line-height: 10px;
-  -moz-margin-end: 1ex;
+  margin-inline-start: 3px;
+  margin-inline-end: 1ex;
 }
 
 /* CSS styles */
 .webconsole-filter-button[category="css"] > .toolbarbutton-menubutton-button:before {
   background-image: linear-gradient(#2DC3F3, #00B6F0);
   border-color: #1BA2CC;
 }
 
--- a/devtools/client/webconsole/moz.build
+++ b/devtools/client/webconsole/moz.build
@@ -1,16 +1,20 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
 
+DIRS += [
+    'net'
+]
+
 DevToolsModules(
     'console-commands.js',
     'console-output.js',
     'hudservice.js',
     'jsterm.js',
     'panel.js',
     'webconsole.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/.eslintrc
@@ -0,0 +1,18 @@
+{
+  "globals": {
+    "Locale": true,
+    "Document": true,
+    "document": true,
+    "Node": true,
+    "Element": true,
+    "MessageEvent": true,
+    "BrowserLoader": true,
+    "addEventListener": true,
+    "DOMParser": true,
+    "dispatchEvent": true,
+    "setTimeout": true
+  },
+  "rules": {
+    "no-unused-vars": [2, {"args": "none"}],
+  }
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/cookies-tab.js
@@ -0,0 +1,69 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const NetInfoGroupList = React.createFactory(require("./net-info-group-list"));
+const Spinner = React.createFactory(require("./spinner"));
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents 'Cookies' tab displayed when the user
+ * expands network log in the Console panel. It's responsible for rendering
+ * sent and received cookies.
+ */
+var CookiesTab = React.createClass({
+  propTypes: {
+    actions: PropTypes.shape({
+      requestData: PropTypes.func.isRequired
+    }),
+    data: PropTypes.object.isRequired,
+  },
+
+  displayName: "CookiesTab",
+
+  render() {
+    let actions = this.props.actions;
+    let file = this.props.data;
+
+    let cookies = file.request.cookies;
+    if (!cookies || !cookies.length) {
+      // TODO: use async action objects as soon as Redux is in place
+      actions.requestData("requestCookies");
+
+      return (
+        Spinner()
+      );
+    }
+
+    // The cookie panel displays two groups of cookies:
+    // 1) Response Cookies
+    // 2) Request Cookies
+    let groups = [{
+      key: "responseCookies",
+      name: Locale.$STR("responseCookies"),
+      params: file.response.cookies
+    }, {
+      key: "requestCookies",
+      name: Locale.$STR("requestCookies"),
+      params: file.request.cookies
+    }];
+
+    return (
+      DOM.div({className: "cookiesTabBox"},
+        DOM.div({className: "panelContent"},
+          NetInfoGroupList({
+            groups: groups
+          })
+        )
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = CookiesTab;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/headers-tab.js
@@ -0,0 +1,73 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const NetInfoGroupList = React.createFactory(require("./net-info-group-list"));
+const Spinner = React.createFactory(require("./spinner"));
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents 'Headers' tab displayed when the user
+ * expands network log in the Console panel. It's responsible for rendering
+ * request and response HTTP headers.
+ */
+var HeadersTab = React.createClass({
+  propTypes: {
+    actions: PropTypes.shape({
+      requestData: PropTypes.func.isRequired
+    }),
+    data: PropTypes.object.isRequired,
+  },
+
+  displayName: "HeadersTab",
+
+  render() {
+    let {data, actions} = this.props;
+    let responseHeaders = data.response.headers;
+    let requestHeaders = data.request.headers;
+
+    // Request headers if they are not available yet.
+    // TODO: use async action objects as soon as Redux is in place
+    if (!requestHeaders) {
+      actions.requestData("requestHeaders");
+    }
+
+    if (!responseHeaders) {
+      actions.requestData("responseHeaders");
+    }
+
+    // TODO: Another groups to implement:
+    // 1) Cached Headers
+    // 2) Headers from upload stream
+    let groups = [{
+      key: "responseHeaders",
+      name: Locale.$STR("responseHeaders"),
+      params: responseHeaders
+    }, {
+      key: "requestHeaders",
+      name: Locale.$STR("requestHeaders"),
+      params: requestHeaders
+    }];
+
+    // If response headers are not available yet, display a spinner
+    if (!responseHeaders || !responseHeaders.length) {
+      groups[0].content = Spinner();
+    }
+
+    return (
+      DOM.div({className: "headersTabBox"},
+        DOM.div({className: "panelContent"},
+          NetInfoGroupList({groups: groups})
+        )
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = HeadersTab;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/moz.build
@@ -0,0 +1,24 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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(
+    'cookies-tab.js',
+    'headers-tab.js',
+    'net-info-body.css',
+    'net-info-body.js',
+    'net-info-group-list.js',
+    'net-info-group.css',
+    'net-info-group.js',
+    'net-info-params.css',
+    'net-info-params.js',
+    'params-tab.js',
+    'post-tab.js',
+    'response-tab.css',
+    'response-tab.js',
+    'size-limit.css',
+    'size-limit.js',
+    'spinner.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-body.css
@@ -0,0 +1,117 @@
+/* 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/. */
+
+/******************************************************************************/
+/* Network Info Body */
+
+.netInfoBody {
+  font-family: var(--net-font-family);
+  font-size: var(--net-font-size);
+  margin: 10px 0 0 0;
+  width: 100%;
+  cursor: default;
+  display: block;
+}
+
+.netInfoBody *:focus {
+  outline: 0 !important;
+}
+
+.netInfoBody .panelContent {
+  word-break: break-all;
+}
+
+/******************************************************************************/
+/* Network Info Body Tabs */
+
+.netInfoBody > .tabs {
+  background-color: transparent;
+  background-image: none;
+  height: 100%;
+}
+
+.netInfoBody > .tabs .tabs-navigation {
+  font-family: var(--net-font-family);
+  font-size: var(--net-font-size);
+  border-bottom-color: var(--net-border);
+  background-color: transparent;
+  text-decoration: none;
+  padding-top: 3px;
+  padding-left: 7px;
+  padding-bottom: 1px;
+  border-bottom: 1px solid var(--net-border);
+}
+
+.netInfoBody > .tabs .tabs-menu {
+  display: table;
+  list-style: none;
+  padding: 0;
+  margin: 0;
+}
+
+/* This is the trick that makes the tab bottom border invisible */
+.netInfoBody > .tabs .tabs-menu-item {
+  position: relative;
+  bottom: -2px;
+  float: left;
+}
+
+.netInfoBody > .tabs .tabs-menu-item a {
+  display: block;
+  border: 1px solid transparent;
+  text-decoration: none;
+  padding: 5px 8px 4px 8px;;
+  font-weight: bold;
+  color: var(--theme-body-color);
+  border-radius: 4px 4px 0 0;
+}
+
+.netInfoBody > .tabs .tab-panel {
+  background-color: var(--theme-body-background);
+  border: 1px solid transparent;
+  border-top: none;
+  padding: 10px;
+  overflow: auto;
+  height: calc(100% - 31px); /* minus the height of the tab bar */
+}
+
+.netInfoBody > .tabs .tab-panel > div,
+.netInfoBody > .tabs .tab-panel > div > div {
+  height: 100%;
+}
+
+.netInfoBody > .tabs .tabs-menu-item.is-active a,
+.netInfoBody > .tabs .tabs-menu-item.is-active a:focus,
+.netInfoBody > .tabs .tabs-menu-item.is-active:hover a {
+  background-color: var(--theme-body-background);
+  border: 1px solid transparent;
+  border-bottom-color: var(--theme-highlight-bluegrey);
+  color: var(--theme-highlight-bluegrey);
+}
+
+.netInfoBody > .tabs .tabs-menu-item:hover a {
+  border: 1px solid transparent;
+  border-bottom: 1px solid var(--net-border);
+  background-color: var(--theme-body-background);
+}
+
+
+/******************************************************************************/
+/* Themes */
+
+.theme-firebug .netInfoBody > .tabs .tab-panel {
+  border-color: var(--net-border);
+}
+
+.theme-firebug .netInfoBody > .tabs .tabs-menu-item.is-active a,
+.theme-firebug .netInfoBody > .tabs .tabs-menu-item.is-active:hover a,
+.theme-firebug .netInfoBody > .tabs .tabs-menu-item.is-active a:focus {
+  border: 1px solid var(--net-border);
+  border-bottom-color: transparent;
+  border-color: var(--net-border);
+}
+
+.theme-firebug .netInfoBody > .tabs .tabs-menu-item:hover a {
+  border-bottom-color: transparent;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-body.js
@@ -0,0 +1,157 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const { createFactories } = require("devtools/client/shared/components/reps/rep-utils");
+const { Tabs, TabPanel } = createFactories(require("devtools/client/jsonview/components/reps/tabs"));
+
+// Network
+const HeadersTab = React.createFactory(require("./headers-tab"));
+const ResponseTab = React.createFactory(require("./response-tab"));
+const ParamsTab = React.createFactory(require("./params-tab"));
+const CookiesTab = React.createFactory(require("./cookies-tab"));
+const PostTab = React.createFactory(require("./post-tab"));
+const NetUtils = require("../utils/net");
+
+// Shortcuts
+const PropTypes = React.PropTypes;
+
+/**
+ * This template renders the basic Network log info body. It's not
+ * visible by default, the user needs to expand the network log
+ * to see it.
+ *
+ * This is the set of tabs displaying details about network events:
+ * 1) Headers - request and response headers
+ * 2) Params - URL parameters
+ * 3) Response - response body
+ * 4) Cookies - request and response cookies
+ * 5) Post - posted data
+ */
+var NetInfoBody = React.createClass({
+  propTypes: {
+    tabActive: PropTypes.number.isRequired,
+    actions: PropTypes.object.isRequired,
+    data: PropTypes.shape({
+      request: PropTypes.object.isRequired,
+      response: PropTypes.object.isRequired
+    })
+  },
+
+  displayName: "NetInfoBody",
+
+  getDefaultProps() {
+    return {
+      tabActive: 1
+    };
+  },
+
+  getInitialState() {
+    return {
+      data: {
+        request: {},
+        response: {}
+      },
+      tabActive: this.props.tabActive,
+    };
+  },
+
+  onTabChanged(index) {
+    this.setState({tabActive: index});
+  },
+
+  hasCookies() {
+    let {request, response} = this.state.data;
+    return NetUtils.getHeaderValue(request.headers, "Cookie") ||
+      NetUtils.getHeaderValue(response.headers, "Cookie");
+  },
+
+  getTabPanels() {
+    let actions = this.props.actions;
+    let data = this.state.data;
+    let {request} = data;
+
+    // Flags for optional tabs. Some tabs are visible only if there
+    // are data to display.
+    let hasParams = request.queryString && request.queryString.length;
+    let hasPostData = request.bodySize > 0;
+
+    let panels = [];
+
+    // Headers tab
+    panels.push(
+      TabPanel({
+        className: "headers",
+        key: "headers",
+        title: Locale.$STR("netRequest.headers")},
+        HeadersTab({data: data, actions: actions})
+      )
+    );
+
+    // URL parameters tab
+    if (hasParams) {
+      panels.push(
+        TabPanel({
+          className: "params",
+          key: "params",
+          title: Locale.$STR("netRequest.params")},
+          ParamsTab({data: data, actions: actions})
+        )
+      );
+    }
+
+    // Posted data tab
+    if (hasPostData) {
+      panels.push(
+        TabPanel({
+          className: "post",
+          key: "post",
+          title: Locale.$STR("netRequest.post")},
+          PostTab({data: data, actions: actions})
+        )
+      );
+    }
+
+    // Response tab
+    panels.push(
+      TabPanel({className: "response", key: "response",
+        title: Locale.$STR("netRequest.response")},
+        ResponseTab({data: data, actions: actions})
+      )
+    );
+
+    // Cookies tab
+    if (this.hasCookies()) {
+      panels.push(
+        TabPanel({
+          className: "cookies",
+          key: "cookies",
+          title: Locale.$STR("netRequest.cookies")},
+          CookiesTab({
+            data: data,
+            actions: actions
+          })
+        )
+      );
+    }
+
+    return panels;
+  },
+
+  render() {
+    let tabActive = this.state.tabActive;
+    let tabPanels = this.getTabPanels();
+    return (
+      Tabs({
+        tabActive: tabActive,
+        onAfterChange: this.onTabChanged},
+        tabPanels
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = NetInfoBody;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-group-list.js
@@ -0,0 +1,47 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const NetInfoGroup = React.createFactory(require("./net-info-group"));
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template is responsible for rendering sections/groups inside tabs.
+ * It's used e.g to display Response and Request headers as separate groups.
+ */
+var NetInfoGroupList = React.createClass({
+  propTypes: {
+    groups: PropTypes.array.isRequired,
+  },
+
+  displayName: "NetInfoGroupList",
+
+  render() {
+    let groups = this.props.groups;
+
+    // Filter out empty groups.
+    groups = groups.filter(group => {
+      return group && ((group.params && group.params.length) || group.content);
+    });
+
+    // Render groups
+    groups = groups.map(group => {
+      group.type = group.key;
+      return NetInfoGroup(group);
+    });
+
+    return (
+      DOM.div({className: "netInfoGroupList"},
+        groups
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = NetInfoGroupList;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-group.css
@@ -0,0 +1,82 @@
+/* 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/. */
+
+/******************************************************************************/
+/* Net Info Group */
+
+.netInfoBody .netInfoGroup {
+  line-height: 13px;
+  color: var(--theme-body-color);
+  padding-bottom: 6px;
+}
+
+/* Last group doesn't need bottom padding */
+.netInfoBody .netInfoGroup:last-child {
+  padding-bottom: 0;
+}
+
+.netInfoBody .netInfoGroup:last-child .netInfoGroupContent {
+  padding-bottom: 0;
+}
+
+.netInfoBody .netInfoGroupTitle {
+  cursor: pointer;
+  font-weight: bold;
+  -moz-user-select: none;
+  cursor: pointer;
+  padding-left: 3px;
+}
+
+.netInfoBody .netInfoGroupTwisty {
+  background-image: url("chrome://devtools/skin/images/controls.png");
+  background-size: 56px 28px;
+  background-position: 0 -14px;
+  background-repeat: no-repeat;
+  width: 14px;
+  height: 14px;
+  cursor: pointer;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.netInfoBody .netInfoGroup.opened .netInfoGroupTwisty {
+  background-position: -14px -14px;
+}
+
+/* Group content is expandable/collapsible by clicking on the title */
+.netInfoBody .netInfoGroupContent {
+  padding-top: 7px;
+  margin-top: 3px;
+  padding-bottom: 14px;
+  border-top: 1px solid var(--net-border);
+  display: none;
+}
+
+/* Toggle group visibility */
+.netInfoBody .netInfoGroup.opened .netInfoGroupContent {
+  display: block;
+}
+
+/******************************************************************************/
+/* Themes */
+
+.theme-dark .netInfoBody .netInfoGroup {
+  color: var(--theme-body-color);
+}
+
+.theme-dark .netInfoBody .netInfoGroup .netInfoGroupTwisty {
+  filter: invert(1);
+}
+
+/* Twisties */
+.theme-firebug .netInfoBody .netInfoGroup .netInfoGroupTwisty {
+  background-image: url("chrome://devtools/skin/images/firebug/twisty-closed-firebug.svg");
+  background-position: 0 2px;
+  background-size: 11px 11px;
+  width: 15px;
+}
+
+.theme-firebug .netInfoBody .netInfoGroup.opened .netInfoGroupTwisty {
+  background-image: url("chrome://devtools/skin/images/firebug/twisty-open-firebug.svg");
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-group.js
@@ -0,0 +1,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 React = require("devtools/client/shared/vendor/react");
+const NetInfoParams = React.createFactory(require("./net-info-params"));
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents a group of data within a tab. For example,
+ * Headers tab has two groups 'Request Headers' and 'Response Headers'
+ * The Response tab can also have two groups 'Raw Data' and 'JSON'
+ */
+var NetInfoGroup = React.createClass({
+  propTypes: {
+    type: PropTypes.string.isRequired,
+    name: PropTypes.string.isRequired,
+    params: PropTypes.array,
+    content: PropTypes.element,
+    open: PropTypes.bool
+  },
+
+  displayName: "NetInfoGroup",
+
+  getDefaultProps() {
+    return {
+      open: true,
+    };
+  },
+
+  getInitialState() {
+    return {
+      open: this.props.open,
+    };
+  },
+
+  onToggle(event) {
+    this.setState({
+      open: !this.state.open
+    });
+  },
+
+  render() {
+    let content = this.props.content;
+
+    if (!content && this.props.params) {
+      content = NetInfoParams({
+        params: this.props.params
+      });
+    }
+
+    let open = this.state.open;
+    let className = open ? "opened" : "";
+
+    return (
+      DOM.div({className: "netInfoGroup" + " " + className + " " +
+        this.props.type},
+        DOM.span({
+          className: "netInfoGroupTwisty",
+          onClick: this.onToggle
+        }),
+        DOM.span({
+          className: "netInfoGroupTitle",
+          onClick: this.onToggle},
+          this.props.name
+        ),
+        DOM.div({className: "netInfoGroupContent"},
+          content
+        )
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = NetInfoGroup;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-params.css
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/******************************************************************************/
+/* Net Info Params */
+
+.netInfoBody .netInfoParamName {
+  padding: 0 10px 0 0;
+  font-weight: bold;
+  vertical-align: top;
+  text-align: right;
+  white-space: nowrap;
+}
+
+.netInfoBody .netInfoParamValue {
+  width: 100%;
+  word-wrap: break-word;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/net-info-params.js
@@ -0,0 +1,58 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template renders list of parameters within a group.
+ * It's essentially a list of name + value pairs.
+ */
+var NetInfoParams = React.createClass({
+  propTypes: {
+    params: PropTypes.arrayOf(PropTypes.shape({
+      name: PropTypes.string.isRequired,
+      value: PropTypes.string.isRequired
+    })).isRequired,
+  },
+
+  displayName: "NetInfoParams",
+
+  render() {
+    let params = this.props.params || [];
+
+    params.sort(function(a, b) {
+      return a.name > b.name ? 1 : -1;
+    });
+
+    let rows = [];
+    params.forEach(param => {
+      rows.push(
+        DOM.tr({key: param.name},
+          DOM.td({className: "netInfoParamName"},
+            DOM.span({title: param.name}, param.name)
+          ),
+          DOM.td({className: "netInfoParamValue"},
+            DOM.code({}, param.value)
+          )
+        )
+      );
+    });
+
+    return (
+      DOM.table({cellPadding: 0, cellSpacing: 0},
+        DOM.tbody({},
+          rows
+        )
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = NetInfoParams;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/params-tab.js
@@ -0,0 +1,41 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+const NetInfoParams = React.createFactory(require("./net-info-params"));
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents 'Params' tab displayed when the user
+ * expands network log in the Console panel. It's responsible for
+ * displaying URL parameters (query string).
+ */
+var ParamsTab = React.createClass({
+  propTypes: {
+    data: PropTypes.shape({
+      request: PropTypes.object.isRequired
+    })
+  },
+
+  displayName: "ParamsTab",
+
+  render() {
+    let data = this.props.data;
+
+    return (
+      DOM.div({className: "paramsTabBox"},
+        DOM.div({className: "panelContent"},
+          NetInfoParams({params: data.request.queryString})
+        )
+      )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = ParamsTab;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/post-tab.js
@@ -0,0 +1,273 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+
+// Reps
+const { createFactories } = require("devtools/client/shared/components/reps/rep-utils");
+const { parseURLEncodedText } = require("devtools/client/shared/components/reps/url");
+const TreeView = React.createFactory(require("devtools/client/shared/components/tree/tree-view"));
+const { Rep } = createFactories(require("devtools/client/shared/components/reps/rep"));
+
+// Network
+const NetInfoParams = React.createFactory(require("./net-info-params"));
+const NetInfoGroupList = React.createFactory(require("./net-info-group-list"));
+const Spinner = React.createFactory(require("./spinner"));
+const SizeLimit = React.createFactory(require("./size-limit"));
+const NetUtils = require("../utils/net");
+const Json = require("../utils/json");
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents 'Post' tab displayed when the user
+ * expands network log in the Console panel. It's responsible for
+ * displaying posted data (HTTP post body).
+ */
+var PostTab = React.createClass({
+  propTypes: {
+    data: PropTypes.shape({
+      request: PropTypes.object.isRequired
+    }),
+    actions: PropTypes.object.isRequired
+  },
+
+  displayName: "PostTab",
+
+  isJson(file) {
+    let postData = file.request.postData;
+    let value = NetUtils.getHeaderValue(file.request.headers, "content-type");
+    return Json.isJSON(value, postData);
+  },
+
+  parseJson(file) {
+    let postData = file.request.postData;
+    if (!postData) {
+      return null;
+    }
+
+    let jsonString = new String(postData.text);
+    return Json.parseJSONString(jsonString);
+  },
+
+  /**
+   * Render JSON post data as an expandable tree.
+   */
+  renderJson(file) {
+    let text = file.request.postData.text;
+    if (!text || isLongString(text)) {
+      return null;
+    }
+
+    if (!this.isJson(file)) {
+      return null;
+    }
+
+    let json = this.parseJson(file);
+    if (!json) {
+      return null;
+    }
+
+    return {
+      key: "json",
+      content: TreeView({
+        columns: [{id: "value"}],
+        object: json,
+        mode: "tiny",
+        renderValue: props => Rep(props)
+      }),
+      name: Locale.$STR("jsonScopeName")
+    };
+  },
+
+  parseXml(file) {
+    let text = file.request.postData.text;
+    if (isLongString(text)) {
+      return null;
+    }
+
+    return NetUtils.parseXml({
+      mimeType: NetUtils.getHeaderValue(file.request.headers, "content-type"),
+      text: text,
+    });
+  },
+
+  isXml(file) {
+    if (isLongString(file.request.postData.text)) {
+      return false;
+    }
+
+    let value = NetUtils.getHeaderValue(file.request.headers, "content-type");
+    if (!value) {
+      return false;
+    }
+
+    return NetUtils.isHTML(value);
+  },
+
+  renderXml(file) {
+    let text = file.request.postData.text;
+    if (!text || isLongString(text)) {
+      return null;
+    }
+
+    if (!this.isXml(file)) {
+      return null;
+    }
+
+    let doc = this.parseXml(file);
+    if (!doc) {
+      return null;
+    }
+
+    // Proper component for rendering XML should be used (see bug 1247392)
+    return null;
+  },
+
+  /**
+   * Multipart post data are parsed and nicely rendered
+   * as an expandable tree of individual parts.
+   */
+  renderMultiPart(file) {
+    let text = file.request.postData.text;
+    if (!text || isLongString(text)) {
+      return;
+    }
+
+    if (NetUtils.isMultiPartRequest(file)) {
+      // TODO: render multi part request (bug: 1247423)
+    }
+
+    return;
+  },
+
+  /**
+   * URL encoded post data are nicely rendered as a list
+   * of parameters.
+   */
+  renderUrlEncoded(file) {
+    let text = file.request.postData.text;
+    if (!text || isLongString(text)) {
+      return null;
+    }
+
+    if (!NetUtils.isURLEncodedRequest(file)) {
+      return null;
+    }
+
+    let lines = text.split("\n");
+    let params = parseURLEncodedText(lines[lines.length - 1]);
+
+    return {
+      key: "url-encoded",
+      content: NetInfoParams({params: params}),
+      name: Locale.$STR("netRequest.params")
+    };
+  },
+
+  renderRawData(file) {
+    let text = file.request.postData.text;
+
+    let group;
+
+    // The post body might reached the limit, so check if we are
+    // dealing with a long string.
+    if (typeof text == "object") {
+      group = {
+        key: "raw-longstring",
+        name: Locale.$STR("netRequest.rawData"),
+        content: DOM.div({className: "netInfoResponseContent"},
+          text.initial,
+          SizeLimit({
+            actions: this.props.actions,
+            data: file.request.postData,
+            message: Locale.$STR("netRequest.sizeLimitMessage"),
+            link: Locale.$STR("netRequest.sizeLimitMessageLink")
+          })
+        )
+      };
+    } else {
+      group = {
+        key: "raw",
+        name: Locale.$STR("netRequest.rawData"),
+        content: DOM.div({className: "netInfoResponseContent"},
+          sanitize(text)
+        )
+      };
+    }
+
+    return group;
+  },
+
+  render() {
+    let actions = this.props.actions;
+    let file = this.props.data;
+
+    if (file.discardRequestBody) {
+      return DOM.span({className: "netInfoBodiesDiscarded"},
+        Locale.$STR("netRequest.requestBodyDiscarded")
+      );
+    }
+
+    let postData = file.request.postData;
+    if (!postData) {
+      // TODO: use async action objects as soon as Redux is in place
+      actions.requestData("requestPostData");
+      return (
+        Spinner()
+      );
+    }
+
+    // Render post body data. The right representation of the data
+    // is picked according to the content type.
+    let groups = [];
+    groups.push(this.renderUrlEncoded(file));
+    // TODO: render multi part request (bug: 1247423)
+    // groups.push(this.renderMultiPart(file));
+    groups.push(this.renderJson(file));
+    groups.push(this.renderXml(file));
+    groups.push(this.renderRawData(file));
+
+    // Filter out empty groups.
+    groups = groups.filter(group => group);
+
+    // The raw response is collapsed by default if a nice formatted
+    // version is available.
+    if (groups.length > 1) {
+      groups[groups.length - 1].open = false;
+    }
+
+    return (
+      DOM.div({className: "postTabBox"},
+        DOM.div({className: "panelContent"},
+          NetInfoGroupList({
+            groups: groups
+          })
+        )
+      )
+    );
+  }
+});
+
+// Helpers
+
+/**
+ * Workaround for a "not well-formed" error that react
+ * reports when there's multipart data passed to render.
+ */
+function sanitize(text) {
+  text = JSON.stringify(text);
+  text = text.replace(/\\r\\n/g, "\r\n").replace(/\\"/g, "\"");
+  return text.slice(1, text.length - 1);
+}
+
+function isLongString(text) {
+  return typeof text == "object";
+}
+
+// Exports from this module
+module.exports = PostTab;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/response-tab.css
@@ -0,0 +1,21 @@
+/* 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/. */
+
+/******************************************************************************/
+/* Response Tab */
+
+.netInfoBody .netInfoBodiesDiscarded {
+  font-style: italic;
+  color: gray;
+}
+
+.netInfoBody .netInfoResponseContent {
+  font-family: monospace;
+  word-wrap: break-word;
+}
+
+.netInfoBody .responseTabBox img {
+  max-width: 300px;
+  max-height: 300px;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/response-tab.js
@@ -0,0 +1,269 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+
+// Reps
+const { createFactories } = require("devtools/client/shared/components/reps/rep-utils");
+const TreeView = React.createFactory(require("devtools/client/shared/components/tree/tree-view"));
+const { Rep } = createFactories(require("devtools/client/shared/components/reps/rep"));
+
+// Network
+const SizeLimit = React.createFactory(require("./size-limit"));
+const NetInfoGroupList = React.createFactory(require("./net-info-group-list"));
+const Spinner = React.createFactory(require("./spinner"));
+const Json = require("../utils/json");
+const NetUtils = require("../utils/net");
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents 'Response' tab displayed when the user
+ * expands network log in the Console panel. It's responsible for
+ * rendering HTTP response body.
+ *
+ * In case of supported response mime-type (e.g. application/json,
+ * text/xml, etc.), the response is parsed using appropriate parser
+ * and rendered accordingly.
+ */
+var ResponseTab = React.createClass({
+  propTypes: {
+    data: PropTypes.shape({
+      request: PropTypes.object.isRequired,
+      response: PropTypes.object.isRequired
+    }),
+    actions: PropTypes.object.isRequired
+  },
+
+  displayName: "ResponseTab",
+
+  // Response Types
+
+  isJson(content) {
+    if (isLongString(content.text)) {
+      return false;
+    }
+
+    return Json.isJSON(content.mimeType, content.text);
+  },
+
+  parseJson(file) {
+    let content = file.response.content;
+    if (isLongString(content.text)) {
+      return null;
+    }
+
+    let jsonString = new String(content.text);
+    return Json.parseJSONString(jsonString);
+  },
+
+  isImage(content) {
+    if (isLongString(content.text)) {
+      return false;
+    }
+
+    return NetUtils.isImage(content.mimeType);
+  },
+
+  isXml(content) {
+    if (isLongString(content.text)) {
+      return false;
+    }
+
+    return NetUtils.isHTML(content.mimeType);
+  },
+
+  parseXml(file) {
+    let content = file.response.content;
+    if (isLongString(content.text)) {
+      return null;
+    }
+
+    return NetUtils.parseXml(content);
+  },
+
+  // Rendering
+
+  renderJson(file) {
+    let content = file.response.content;
+    if (!this.isJson(content)) {
+      return null;
+    }
+
+    let json = this.parseJson(file);
+    if (!json) {
+      return null;
+    }
+
+    return {
+      key: "json",
+      content: TreeView({
+        columns: [{id: "value"}],
+        object: json,
+        mode: "tiny",
+        renderValue: props => Rep(props)
+      }),
+      name: Locale.$STR("jsonScopeName")
+    };
+  },
+
+  renderImage(file) {
+    let content = file.response.content;
+    if (!this.isImage(content)) {
+      return null;
+    }
+
+    let dataUri = "data:" + content.mimeType + ";base64," + content.text;
+    return {
+      key: "image",
+      content: DOM.img({src: dataUri}),
+      name: Locale.$STR("netRequest.image")
+    };
+  },
+
+  renderXml(file) {
+    let content = file.response.content;
+    if (!this.isXml(content)) {
+      return null;
+    }
+
+    let doc = this.parseXml(file);
+    if (!doc) {
+      return null;
+    }
+
+    // Proper component for rendering XML should be used (see bug 1247392)
+    return null;
+  },
+
+  /**
+   * If full response text is available, let's try to parse and
+   * present nicely according to the underlying format.
+   */
+  renderFormattedResponse(file) {
+    let content = file.response.content;
+    if (typeof content.text == "object") {
+      return null;
+    }
+
+    let group = this.renderJson(file);
+    if (group) {
+      return group;
+    }
+
+    group = this.renderImage(file);
+    if (group) {
+      return group;
+    }
+
+    group = this.renderXml(file);
+    if (group) {
+      return group;
+    }
+  },
+
+  renderRawResponse(file) {
+    let group;
+    let content = file.response.content;
+
+    // The response might reached the limit, so check if we are
+    // dealing with a long string.
+    if (typeof content.text == "object") {
+      group = {
+        key: "raw-longstring",
+        name: Locale.$STR("netRequest.rawData"),
+        content: DOM.div({className: "netInfoResponseContent"},
+          content.text.initial,
+          SizeLimit({
+            actions: this.props.actions,
+            data: content,
+            message: Locale.$STR("netRequest.sizeLimitMessage"),
+            link: Locale.$STR("netRequest.sizeLimitMessageLink")
+          })
+        )
+      };
+    } else {
+      group = {
+        key: "raw",
+        name: Locale.$STR("netRequest.rawData"),
+        content: DOM.div({className: "netInfoResponseContent"},
+          content.text
+        )
+      };
+    }
+
+    return group;
+  },
+
+  /**
+   * The response panel displays two groups:
+   *
+   * 1) Formatted response (in case of supported format, e.g. JSON, XML, etc.)
+   * 2) Raw response data (always displayed if not discarded)
+   */
+  render() {
+    let actions = this.props.actions;
+    let file = this.props.data;
+
+    // If response bodies are discarded (not collected) let's just
+    // display a info message indicating what to do to collect even
+    // response bodies.
+    if (file.discardResponseBody) {
+      return DOM.span({className: "netInfoBodiesDiscarded"},
+        Locale.$STR("netRequest.responseBodyDiscarded")
+      );
+    }
+
+    // Request for the response content is done only if the response
+    // is not fetched yet - i.e. the `content.text` is undefined.
+    // Empty content.text` can also be a valid response either
+    // empty or not available yet.
+    let content = file.response.content;
+    if (!content || typeof (content.text) == "undefined") {
+      // TODO: use async action objects as soon as Redux is in place
+      actions.requestData("responseContent");
+
+      return (
+        Spinner()
+      );
+    }
+
+    // Render response body data. The right representation of the data
+    // is picked according to the content type.
+    let groups = [];
+    groups.push(this.renderFormattedResponse(file));
+    groups.push(this.renderRawResponse(file));
+
+    // Filter out empty groups.
+    groups = groups.filter(group => group);
+
+    // The raw response is collapsed by default if a nice formatted
+    // version is available.
+    if (groups.length > 1) {
+      groups[1].open = false;
+    }
+
+    return (
+      DOM.div({className: "responseTabBox"},
+        DOM.div({className: "panelContent"},
+          NetInfoGroupList({
+            groups: groups
+          })
+        )
+      )
+    );
+  }
+});
+
+// Helpers
+
+function isLongString(text) {
+  return typeof text == "object";
+}
+
+// Exports from this module
+module.exports = ResponseTab;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/size-limit.css
@@ -0,0 +1,17 @@
+/* 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/. */
+
+/******************************************************************************/
+/* Response Size Limit */
+
+.netInfoBody .netInfoSizeLimit {
+  font-family: var(--net-font-family);
+  font-size: var(--net-font-size);
+  font-weight: bold;
+  padding-top: 10px;
+}
+
+.netInfoBody .netInfoSizeLimit .objectLink {
+  color: var(--theme-highlight-blue);
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/size-limit.js
@@ -0,0 +1,62 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+
+// Shortcuts
+const DOM = React.DOM;
+const PropTypes = React.PropTypes;
+
+/**
+ * This template represents a size limit notification message
+ * used e.g. in the Response tab when response body exceeds
+ * size limit. The message contains a link allowing the user
+ * to fetch the rest of the data from the backend (debugger server).
+ */
+var SizeLimit = React.createClass({
+  propTypes: {
+    data: PropTypes.object.isRequired,
+    message: PropTypes.string.isRequired,
+    link: PropTypes.string.isRequired,
+    actions: PropTypes.shape({
+      resolveString: PropTypes.func.isRequired
+    }),
+  },
+
+  displayName: "SizeLimit",
+
+  // Event Handlers
+
+  onClickLimit(event) {
+    let actions = this.props.actions;
+    let content = this.props.data;
+
+    actions.resolveString(content, "text");
+  },
+
+  // Rendering
+
+  render() {
+    let message = this.props.message;
+    let link = this.props.link;
+    let reLink = /^(.*)\{\{link\}\}(.*$)/;
+    let m = message.match(reLink);
+
+    return (
+        DOM.div({className: "netInfoSizeLimit"},
+          DOM.span({}, m[1]),
+          DOM.a({
+            className: "objectLink",
+            onClick: this.onClickLimit},
+              link
+          ),
+          DOM.span({}, m[2])
+        )
+    );
+  }
+});
+
+// Exports from this module
+module.exports = SizeLimit;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/components/spinner.js
@@ -0,0 +1,26 @@
+/* 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 React = require("devtools/client/shared/vendor/react");
+
+// Shortcuts
+const DOM = React.DOM;
+
+/**
+ * This template represents a throbber displayed when the UI
+ * is waiting for data coming from the backend (debugging server).
+ */
+var Spinner = React.createClass({
+  displayName: "Spinner",
+
+  render() {
+    return (
+      DOM.div({className: "devtools-throbber"})
+    );
+  }
+});
+
+// Exports from this module
+module.exports = Spinner;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/data-provider.js
@@ -0,0 +1,66 @@
+/* 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 promise = require("promise");
+
+/**
+ * Map of pending requests. Used mainly by tests to wait
+ * till things are ready.
+ */
+var promises = new Map();
+
+/**
+ * This object is used to fetch network data from the backend.
+ * Communication with the chrome scope is based on message
+ * exchange.
+ */
+var DataProvider = {
+  hasPendingRequests: function() {
+    return promises.size > 0;
+  },
+
+  requestData: function(client, actor, method) {
+    let key = actor + ":" + method;
+    let p = promises.get(key);
+    if (p) {
+      return p;
+    }
+
+    let deferred = promise.defer();
+    let realMethodName = "get" + method.charAt(0).toUpperCase() +
+      method.slice(1);
+
+    if (!client[realMethodName]) {
+      return null;
+    }
+
+    client[realMethodName](actor, response => {
+      promises.delete(key);
+      deferred.resolve(response);
+    });
+
+    promises.set(key, deferred.promise);
+    return deferred.promise;
+  },
+
+  resolveString: function(client, stringGrip) {
+    let key = stringGrip.actor + ":getString";
+    let p = promises.get(key);
+    if (p) {
+      return p;
+    }
+
+    p = client.getString(stringGrip).then(result => {
+      promises.delete(key);
+      return result;
+    });
+
+    promises.set(key, p);
+    return p;
+  },
+};
+
+// Exports from this module
+module.exports = DataProvider;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/main.js
@@ -0,0 +1,100 @@
+/* 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";
+
+var { utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://devtools/client/shared/browser-loader.js");
+
+// Initialize module loader and load all modules of the new inline
+// preview feature. The entire code-base doesn't need any extra
+// privileges and runs entirely in content scope.
+const rootUrl = "resource://devtools/client/webconsole/net/";
+const require = BrowserLoader({
+  baseURI: rootUrl,
+  window: this}).require;
+
+const NetRequest = require("./net-request");
+const { loadSheet } = require("sdk/stylesheet/utils");
+
+// Localization
+const { Services } = Cu.import("resource://gre/modules/Services.jsm", {});
+var networkStrings = Services.strings.createBundle(
+  "chrome://devtools/locale/netmonitor.properties");
+
+// Stylesheets
+var styleSheets = [
+  "resource://devtools/client/jsonview/css/toolbar.css",
+  "resource://devtools/client/shared/components/tree/tree-view.css",
+  "resource://devtools/client/shared/components/reps/reps.css",
+  "resource://devtools/client/webconsole/net/net-request.css",
+  "resource://devtools/client/webconsole/net/components/size-limit.css",
+  "resource://devtools/client/webconsole/net/components/net-info-body.css",
+  "resource://devtools/client/webconsole/net/components/net-info-group.css",
+  "resource://devtools/client/webconsole/net/components/net-info-params.css",
+  "resource://devtools/client/webconsole/net/components/response-tab.css"
+];
+
+// Load theme stylesheets into the Console frame. This should be
+// done automatically by UI Components as soon as we have consensus
+// on the right CSS strategy FIXME.
+// It would also be nice to include them using @import.
+styleSheets.forEach(url => {
+  loadSheet(this, url, "author");
+});
+
+// Localization API used by React components
+// accessing strings from *.properties file.
+// Example:
+//   let localizedString = Locale.$STR('string-key');
+//
+// Resources:
+// http://l20n.org/
+// https://github.com/yahoo/react-intl
+this.Locale = {
+  $STR: key => {
+    try {
+      return networkStrings.GetStringFromName(key);
+    } catch (err) {
+      Cu.reportError(key + ": " + err);
+    }
+  }
+};
+
+// List of NetRequest instances represents the state.
+// As soon as Redux is in place it should be maintained using a reducer.
+var netRequests = new Map();
+
+/**
+ * This function handles network events received from the backend. It's
+ * executed from within the webconsole.js
+ */
+function onNetworkEvent(log) {
+  // The 'from' field is set only in case of a 'networkEventUpdate' packet.
+  // The initial 'networkEvent' packet uses 'actor'.
+  // Check if NetRequest object is already created for this event actor and
+  // if there is none make sure to create one.
+  let response = log.response;
+  let netRequest = response.from ? netRequests.get(response.from) : null;
+  if (!netRequest && !log.update) {
+    netRequest = new NetRequest(log);
+    netRequests.set(response.actor, netRequest);
+  }
+
+  if (!netRequest) {
+    return;
+  }
+
+  if (log.update) {
+    netRequest.updateBody(response);
+  }
+
+  return;
+}
+
+// Make the 'onNetworkEvent' accessible from chrome (see webconsole.js)
+this.NetRequest = {
+  onNetworkEvent: onNetworkEvent
+};
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/moz.build
@@ -0,0 +1,16 @@
+# vim: set filetype=python:
+# 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/.
+
+DIRS += [
+    'components',
+    'utils'
+]
+
+DevToolsModules(
+    'data-provider.js',
+    'main.js',
+    'net-request.css',
+    'net-request.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/net-request.css
@@ -0,0 +1,41 @@
+  /* 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/. */
+
+/******************************************************************************/
+/* General */
+
+:root {
+  --net-border: #d7d7d7;
+  --net-font-family: monospace;
+  --net-font-size: 11px;
+}
+
+:root.theme-firebug {
+  --net-font-family: Lucida Grande, Tahoma, sans-serif;
+}
+
+:root.theme-dark {
+  --net-border: #5f7387;
+}
+
+/******************************************************************************/
+/* Network log */
+
+/* No background if a Net log is opened */
+.netRequest.message.opened,
+.netRequest.message.opened:hover {
+  background: transparent !important;
+}
+
+/******************************************************************************/
+/* Themes */
+
+.theme-dark .netRequest.opened:hover,
+.theme-dark .netRequest.opened {
+  background: transparent;
+}
+
+.theme-firebug .netRequest.message.opened:hover {
+  background-image: linear-gradient(rgba(214, 233, 246, 0.8), rgba(255, 255, 255, 1.6)) !important;
+}
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/net-request.js
@@ -0,0 +1,306 @@
+/* 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";
+
+// React
+const React = require("devtools/client/shared/vendor/react");
+const ReactDOM = require("devtools/client/shared/vendor/react-dom");
+
+// Reps
+const { parseURLParams } = require("devtools/client/shared/components/reps/url");
+
+// Network
+const { cancelEvent, isLeftClick } = require("./utils/events");
+const NetInfoBody = React.createFactory(require("./components/net-info-body"));
+const DataProvider = require("./data-provider");
+
+// Constants
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * This object represents a network log in the Console panel (and in the
+ * Network panel in the future).
+ * It's associated with an existing log and so, also with an existing
+ * element in the DOM.
+ *
+ * The object neither render no request for more data by default. It only
+ * reqisters a click listener to the associated log entry (a network event)
+ * and changes the class attribute of the log entry, so a twisty icon
+ * appears to indicates that there are more details displayed if the
+ * log entry is expanded.
+ *
+ * When the user expands the log, data are requested from the backend
+ * and rendered directly within the Console iframe.
+ */
+function NetRequest(log) {
+  this.initialize(log);
+}
+
+NetRequest.prototype = {
+  initialize: function(log) {
+    this.client = log.client;
+
+    // 'this.file' field is following HAR spec.
+    // http://www.softwareishard.com/blog/har-12-spec/
+    this.file = log.response;
+    this.parentNode = log.node;
+    this.file.request.queryString = parseURLParams(this.file.request.url);
+
+    // Map of fetched responses (to avoid unnecessary RDP round trip).
+    this.cachedResponses = new Map();
+
+    let doc = this.parentNode.ownerDocument;
+    let twisty = doc.createElementNS(XHTML_NS, "a");
+    twisty.className = "theme-twisty";
+    twisty.href = "#";
+
+    let messageBody = this.parentNode.querySelector(".message-body-wrapper");
+    this.parentNode.insertBefore(twisty, messageBody);
+    this.parentNode.setAttribute("collapsible", true);
+
+    this.parentNode.classList.add("netRequest");
+
+    // Register a click listener.
+    this.addClickListener();
+  },
+
+  addClickListener: function() {
+    // Add an event listener to toggle the expanded state when clicked.
+    // The event bubbling is canceled if the user clicks on the log
+    // itself (not on the expanded body), so opening of the default
+    // modal dialog is avoided.
+    this.parentNode.addEventListener("click", (event) => {
+      if (!isLeftClick(event)) {
+        return;
+      }
+
+      // Clicking on the toggle button or the method expands/collapses
+      // the body with HTTP details.
+      let classList = event.originalTarget.classList;
+      if (!(classList.contains("theme-twisty") ||
+        classList.contains("method"))) {
+        return;
+      }
+
+      // Alright, the user is clicking fine, let's open HTTP details!
+      this.onToggleBody(event);
+
+      // Avoid the default modal dialog
+      cancelEvent(event);
+    }, true);
+  },
+
+  onToggleBody: function(event) {
+    let target = event.currentTarget;
+    let logRow = target.closest(".netRequest");
+    logRow.classList.toggle("opened");
+
+    let twisty = this.parentNode.querySelector(".theme-twisty");
+    if (logRow.classList.contains("opened")) {
+      twisty.setAttribute("open", true);
+    } else {
+      twisty.removeAttribute("open");
+    }
+
+    let isOpen = logRow.classList.contains("opened");
+    if (isOpen) {
+      this.renderBody();
+    } else {
+      this.closeBody();
+    }
+  },
+
+  /**
+   * Executed when 'networkEventUpdate' is received from the backend.
+   */
+  updateBody: function(response) {
+    // 'networkEventUpdate' event indicates that there are new data
+    // available on the backend. The following logic checks the response
+    // cache and if this data has been already requested before they
+    // need to be updated now (re-requested).
+    let method = response.updateType;
+    let cached = this.cachedResponses.get(method);
+    if (cached) {
+      this.cachedResponses.delete(method);
+      this.requestData(method);
+    }
+  },
+
+  /**
+   * Close network inline preview body.
+   */
+  closeBody: function() {
+    this.netInfoBodyBox.parentNode.removeChild(this.netInfoBodyBox);
+  },
+
+  /**
+   * Render network inline preview body.
+   */
+  renderBody: function() {
+    let messageBody = this.parentNode.querySelector(".message-body-wrapper");
+
+    // Create box for all markup rendered by ReactJS. Since we are
+    // rendering within webconsole.xul (i.e. XUL document) we need
+    // to explicitly specify XHTML namespace.
+    let doc = messageBody.ownerDocument;
+    this.netInfoBodyBox = doc.createElementNS(XHTML_NS, "div");
+    this.netInfoBodyBox.classList.add("netInfoBody");
+    messageBody.appendChild(this.netInfoBodyBox);
+
+    // As soon as Redux is in place state and actions will come from
+    // separate modules.
+    let body = NetInfoBody({
+      actions: this
+    });
+
+    // Render net info body!
+    this.body = ReactDOM.render(body, this.netInfoBodyBox);
+
+    this.refresh();
+  },
+
+  /**
+   * Render top level ReactJS component.
+   */
+  refresh: function() {
+    if (!this.netInfoBodyBox) {
+      return;
+    }
+
+    // TODO: As soon as Redux is in place there will be reducer
+    // computing a new state.
+    let newState = Object.assign({}, this.body.state, {
+      data: this.file
+    });
+
+    this.body.setState(newState);
+  },
+
+  // Communication with the backend
+
+  requestData: function(method) {
+    // If the response has already been received bail out.
+    let response = this.cachedResponses.get(method);
+    if (response) {
+      return;
+    }
+
+    // Set an attribute indicating that this net log is waiting for
+    // data coming from the backend. Intended mainly for tests.
+    this.parentNode.setAttribute("loading", "true");
+
+    let actor = this.file.actor;
+    DataProvider.requestData(this.client, actor, method).then(args => {
+      this.cachedResponses.set(method, args);
+      this.onRequestData(method, args);
+
+      if (!DataProvider.hasPendingRequests()) {
+        this.parentNode.removeAttribute("loading");
+
+        // Fire an event indicating that all pending requests for
+        // data from the backend has finished. Intended for tests.
+        // Do it asynchronously so, it's done after all handlers
+        // for the current promise are executed.
+        setTimeout(() => {
+          let event = document.createEvent("Event");
+          event.initEvent("netlog-no-pending-requests", true, true);
+          this.parentNode.dispatchEvent(event);
+        });
+      }
+    });
+  },
+
+  onRequestData: function(method, response) {
+    // TODO: This code will be part of a reducer.
+    let result;
+    switch (method) {
+      case "requestHeaders":
+        result = this.onRequestHeaders(response);
+        break;
+      case "responseHeaders":
+        result = this.onResponseHeaders(response);
+        break;
+      case "requestCookies":
+        result = this.onRequestCookies(response);
+        break;
+      case "responseCookies":
+        result = this.onResponseCookies(response);
+        break;
+      case "responseContent":
+        result = this.onResponseContent(response);
+        break;
+      case "requestPostData":
+        result = this.onRequestPostData(response);
+        break;
+    }
+
+    result.then(() => {
+      this.refresh();
+    });
+  },
+
+  onRequestHeaders: function(response) {
+    this.file.request.headers = response.headers;
+
+    return this.resolveHeaders(this.file.request.headers);
+  },
+
+  onResponseHeaders: function(response) {
+    this.file.response.headers = response.headers;
+
+    return this.resolveHeaders(this.file.response.headers);
+  },
+
+  onResponseContent: function(response) {
+    let content = response.content;
+
+    for (let p in content) {
+      this.file.response.content[p] = content[p];
+    }
+
+    return Promise.resolve();
+  },
+
+  onRequestPostData: function(response) {
+    this.file.request.postData = response.postData;
+    return Promise.resolve();
+  },
+
+  onRequestCookies: function(response) {
+    this.file.request.cookies = response.cookies;
+    return this.resolveHeaders(this.file.request.cookies);
+  },
+
+  onResponseCookies: function(response) {
+    this.file.response.cookies = response.cookies;
+    return this.resolveHeaders(this.file.response.cookies);
+  },
+
+  resolveHeaders: function(headers) {
+    let promises = [];
+
+    for (let header of headers) {
+      if (typeof header.value == "object") {
+        promises.push(this.resolveString(header.value).then(value => {
+          header.value = value;
+        }));
+      }
+    }
+
+    return Promise.all(promises);
+  },
+
+  resolveString: function(object, propName) {
+    let stringGrip = object[propName];
+    if (typeof stringGrip == "object") {
+      DataProvider.resolveString(this.client, stringGrip).then(args => {
+        object[propName] = args;
+        this.refresh();
+      });
+    }
+  }
+};
+
+// Exports from this module
+module.exports = NetRequest;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/utils/events.js
@@ -0,0 +1,21 @@
+/* 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";
+
+function isLeftClick(event, allowKeyModifiers) {
+  return event.button === 0 && (allowKeyModifiers || noKeyModifiers(event));
+}
+
+function noKeyModifiers(event) {
+  return !event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
+}
+
+function cancelEvent(event) {
+  event.stopPropagation();
+  event.preventDefault();
+}
+
+// Exports from this module
+exports.isLeftClick = isLeftClick;
+exports.cancelEvent = cancelEvent;
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/utils/json.js
@@ -0,0 +1,234 @@
+/* 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";
+
+// List of JSON content types.
+const contentTypes = {
+  "text/plain": 1,
+  "text/javascript": 1,
+  "text/x-javascript": 1,
+  "text/json": 1,
+  "text/x-json": 1,
+  "application/json": 1,
+  "application/x-json": 1,
+  "application/javascript": 1,
+  "application/x-javascript": 1,
+  "application/json-rpc": 1
+};
+
+// Implementation
+var Json = {};
+
+/**
+ * Parsing JSON
+ */
+Json.parseJSONString = function(jsonString) {
+  if (!jsonString.length) {
+    return null;
+  }
+
+  let regex, matches;
+
+  let first = firstNonWs(jsonString);
+  if (first !== "[" && first !== "{") {
+    // This (probably) isn't pure JSON. Let's try to strip various sorts
+    // of XSSI protection/wrapping and see if that works better.
+
+    // Prototype-style secure requests
+    regex = /^\s*\/\*-secure-([\s\S]*)\*\/\s*$/;
+    matches = regex.exec(jsonString);
+    if (matches) {
+      jsonString = matches[1];
+
+      if (jsonString[0] === "\\" && jsonString[1] === "n") {
+        jsonString = jsonString.substr(2);
+      }
+
+      if (jsonString[jsonString.length - 2] === "\\" &&
+        jsonString[jsonString.length - 1] === "n") {
+        jsonString = jsonString.substr(0, jsonString.length - 2);
+      }
+    }
+
+    // Google-style (?) delimiters
+    if (jsonString.indexOf("&&&START&&&") !== -1) {
+      regex = /&&&START&&&([\s\S]*)&&&END&&&/;
+      matches = regex.exec(jsonString);
+      if (matches) {
+        jsonString = matches[1];
+      }
+    }
+
+    // while(1);, for(;;);, and )]}'
+    regex = /^\s*(\)\]\}[^\n]*\n|while\s*\(1\);|for\s*\(;;\);)([\s\S]*)/;
+    matches = regex.exec(jsonString);
+    if (matches) {
+      jsonString = matches[2];
+    }
+
+    // JSONP
+    regex = /^\s*([A-Za-z0-9_$.]+\s*(?:\[.*\]|))\s*\(([\s\S]*)\)/;
+    matches = regex.exec(jsonString);
+    if (matches) {
+      jsonString = matches[2];
+    }
+  }
+
+  try {
+    return JSON.parse(jsonString);
+  } catch (err) {
+    // eslint-disable-line no-empty
+  }
+
+  // Give up if we don't have valid start, to avoid some unnecessary overhead.
+  first = firstNonWs(jsonString);
+  if (first !== "[" && first !== "{" && isNaN(first) && first !== '"') {
+    return null;
+  }
+
+  // Remove JavaScript comments, quote non-quoted identifiers, and merge
+  // multi-line structures like |{"a": 1} \n {"b": 2}| into a single JSON
+  // object [{"a": 1}, {"b": 2}].
+  jsonString = pseudoJsonToJson(jsonString);
+
+  try {
+    return JSON.parse(jsonString);
+  } catch (err) {
+    // eslint-disable-line no-empty
+  }
+
+  return null;
+};
+
+function firstNonWs(str) {
+  for (let i = 0, len = str.length; i < len; i++) {
+    let ch = str[i];
+    if (ch !== " " && ch !== "\n" && ch !== "\t" && ch !== "\r") {
+      return ch;
+    }
+  }
+  return "";
+}
+
+function pseudoJsonToJson(json) {
+  let ret = "";
+  let at = 0, lasti = 0, lastch = "", hasMultipleParts = false;
+  for (let i = 0, len = json.length; i < len; ++i) {
+    let ch = json[i];
+    if (/\s/.test(ch)) {
+      continue;
+    }
+
+    if (ch === '"') {
+      // Consume a string.
+      ++i;
+      while (i < len) {
+        if (json[i] === "\\") {
+          ++i;
+        } else if (json[i] === '"') {
+          break;
+        }
+        ++i;
+      }
+    } else if (ch === "'") {
+      // Convert an invalid string into a valid one.
+      ret += json.slice(at, i) + "\"";
+      at = i + 1;
+      ++i;
+
+      while (i < len) {
+        if (json[i] === "\\") {
+          ++i;
+        } else if (json[i] === "'") {
+          break;
+        }
+        ++i;
+      }
+
+      if (i < len) {
+        ret += json.slice(at, i) + "\"";
+        at = i + 1;
+      }
+    } else if ((ch === "[" || ch === "{") &&
+        (lastch === "]" || lastch === "}")) {
+      // Multiple JSON messages in one... Make it into a single array by
+      // inserting a comma and setting the "multiple parts" flag.
+      ret += json.slice(at, i) + ",";
+      hasMultipleParts = true;
+      at = i;
+    } else if (lastch === "," && (ch === "]" || ch === "}")) {
+      // Trailing commas in arrays/objects.
+      ret += json.slice(at, lasti);
+      at = i;
+    } else if (lastch === "/" && lasti === i - 1) {
+      // Some kind of comment; remove it.
+      if (ch === "/") {
+        ret += json.slice(at, i - 1);
+        at = i + json.slice(i).search(/\n|\r|$/);
+        i = at - 1;
+      } else if (ch === "*") {
+        ret += json.slice(at, i - 1);
+        at = json.indexOf("*/", i + 1) + 2;
+        if (at === 1) {
+          at = len;
+        }
+        i = at - 1;
+      }
+      ch = "\0";
+    } else if (/[a-zA-Z$_]/.test(ch) && lastch !== ":") {
+      // Non-quoted identifier. Quote it.
+      ret += json.slice(at, i) + "\"";
+      at = i;
+      i = i + json.slice(i).search(/[^a-zA-Z0-9$_]|$/);
+      ret += json.slice(at, i) + "\"";
+      at = i;
+    }
+
+    lastch = ch;
+    lasti = i;
+  }
+
+  ret += json.slice(at);
+  if (hasMultipleParts) {
+    ret = "[" + ret + "]";
+  }
+
+  return ret;
+}
+
+Json.isJSON = function(contentType, data) {
+  // Workaround for JSON responses without proper content type
+  // Let's consider all responses starting with "{" as JSON. In the worst
+  // case there will be an exception when parsing. This means that no-JSON
+  // responses (and post data) (with "{") can be parsed unnecessarily,
+  // which represents a little overhead, but this happens only if the request
+  // is actually expanded by the user in the UI (Net & Console panels).
+  // Do a manual string search instead of checking (data.strip()[0] === "{")
+  // to improve performance/memory usage.
+  let len = data ? data.length : 0;
+  for (let i = 0; i < len; i++) {
+    let ch = data.charAt(i);
+    if (ch === "{") {
+      return true;
+    }
+
+    if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
+      continue;
+    }
+
+    break;
+  }
+
+  if (!contentType) {
+    return false;
+  }
+
+  contentType = contentType.split(";")[0];
+  contentType = contentType.trim();
+  return !!contentTypes[contentType];
+};
+
+// Exports from this module
+module.exports = Json;
+
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/utils/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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(
+    'events.js',
+    'json.js',
+    'net.js',
+)
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/net/utils/net.js
@@ -0,0 +1,134 @@
+/* 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 mimeCategoryMap = {
+  "text/plain": "txt",
+  "application/octet-stream": "bin",
+  "text/html": "html",
+  "text/xml": "html",
+  "application/xml": "html",
+  "application/rss+xml": "html",
+  "application/atom+xml": "html",
+  "application/xhtml+xml": "html",
+  "application/mathml+xml": "html",
+  "application/rdf+xml": "html",
+  "text/css": "css",
+  "application/x-javascript": "js",
+  "text/javascript": "js",
+  "application/javascript": "js",
+  "text/ecmascript": "js",
+  "application/ecmascript": "js",
+  "image/jpeg": "image",
+  "image/jpg": "image",
+  "image/gif": "image",
+  "image/png": "image",
+  "image/bmp": "image",
+  "application/x-shockwave-flash": "plugin",
+  "application/x-silverlight-app": "plugin",
+  "video/x-flv": "media",
+  "audio/mpeg3": "media",
+  "audio/x-mpeg-3": "media",
+  "video/mpeg": "media",
+  "video/x-mpeg": "media",
+  "video/webm": "media",
+  "video/mp4": "media",
+  "video/ogg": "media",
+  "audio/ogg": "media",
+  "application/ogg": "media",
+  "application/x-ogg": "media",
+  "application/x-midi": "media",
+  "audio/midi": "media",
+  "audio/x-mid": "media",
+  "audio/x-midi": "media",
+  "music/crescendo": "media",
+  "audio/wav": "media",
+  "audio/x-wav": "media",
+  "application/x-woff": "font",
+  "application/font-woff": "font",
+  "application/x-font-woff": "font",
+  "application/x-ttf": "font",
+  "application/x-font-ttf": "font",
+  "font/ttf": "font",
+  "font/woff": "font",
+  "application/x-otf": "font",
+  "application/x-font-otf": "font"
+};
+
+var NetUtils = {};
+
+NetUtils.isImage = function(contentType) {
+  if (!contentType) {
+    return false;
+  }
+
+  contentType = contentType.split(";")[0];
+  contentType = contentType.trim();
+  return mimeCategoryMap[contentType] == "image";
+};
+
+NetUtils.isHTML = function(contentType) {
+  if (!contentType) {
+    return false;
+  }
+
+  contentType = contentType.split(";")[0];
+  contentType = contentType.trim();
+  return mimeCategoryMap[contentType] == "html";
+};
+
+NetUtils.getHeaderValue = function(headers, name) {
+  if (!headers) {
+    return null;
+  }
+
+  name = name.toLowerCase();
+  for (let i = 0; i < headers.length; ++i) {
+    let headerName = headers[i].name.toLowerCase();
+    if (headerName == name) {
+      return headers[i].value;
+    }
+  }
+};
+
+NetUtils.parseXml = function(content) {
+  let contentType = content.mimeType.split(";")[0];
+  contentType = contentType.trim();
+
+  let parser = new DOMParser();
+  let doc = parser.parseFromString(content.text, contentType);
+  let root = doc.documentElement;
+
+  // Error handling
+  let nsURI = "http://www.mozilla.org/newlayout/xml/parsererror.xml";
+  if (root.namespaceURI == nsURI && root.nodeName == "parsererror") {
+    return null;
+  }
+
+  return doc;
+};
+
+NetUtils.isURLEncodedRequest = function(file) {
+  let mimeType = "application/x-www-form-urlencoded";
+
+  let postData = file.request.postData;
+  if (postData && postData.text) {
+    let text = postData.text.toLowerCase();
+    if (text.startsWith("content-type: " + mimeType)) {
+      return true;
+    }
+  }
+
+  let value = NetUtils.getHeaderValue(file.request.headers, "content-type");
+  return value && value.startsWith(mimeType);
+};
+
+NetUtils.isMultiPartRequest = function(file) {
+  let mimeType = "multipart/form-data";
+  let value = NetUtils.getHeaderValue(file.request.headers, "content-type");
+  return value && value.startsWith(mimeType);
+};
+
+// Exports from this module
+module.exports = NetUtils;
--- a/devtools/client/webconsole/webconsole.js
+++ b/devtools/client/webconsole/webconsole.js
@@ -1622,16 +1622,25 @@ WebConsoleFrame.prototype = {
 
     this._addMessageLinkCallback(urlNode, onClick);
     this._addMessageLinkCallback(statusNode, onClick);
 
     networkInfo.node = messageNode;
 
     this._updateNetMessage(actorId);
 
+    if (this.window.NetRequest) {
+      this.window.NetRequest.onNetworkEvent({
+        client: this.webConsoleClient,
+        response: networkInfo,
+        node: messageNode,
+        update: false
+      });
+    }
+
     return messageNode;
   },
 
   /**
    * Create a mixed content warning Node.
    *
    * @param linkNode
    *        Parent to the requested urlNode.
@@ -1822,16 +1831,25 @@ WebConsoleFrame.prototype = {
    *
    * @param object networkInfo
    *        The network request information.
    * @param object packet
    *        Update details.
    */
   handleNetworkEventUpdate: function(networkInfo, packet) {
     if (networkInfo.node && this._updateNetMessage(packet.from)) {
+      if (this.window.NetRequest) {
+        this.window.NetRequest.onNetworkEvent({
+          client: this.webConsoleClient,
+          response: packet,
+          node: networkInfo.node,
+          update: true
+        });
+      }
+
       this.emit("new-messages", new Set([{
         update: true,
         node: networkInfo.node,
         response: packet,
       }]));
     }
 
     // For unit tests we pass the HTTP activity object to the test callback,
--- a/devtools/client/webconsole/webconsole.xul
+++ b/devtools/client/webconsole/webconsole.xul
@@ -22,16 +22,17 @@
         browserConsoleTitle="&browserConsole.title;"
         windowtype="devtools:webconsole"
         width="900" height="350"
         persist="screenX screenY width height sizemode">
 
   <script type="application/javascript;version=1.8"
           src="chrome://devtools/content/shared/theme-switching.js"/>
   <script type="text/javascript" src="chrome://global/content/globalOverlay.js"/>
+  <script type="text/javascript" src="resource://devtools/client/webconsole/net/main.js"/>
   <script type="text/javascript"><![CDATA[
 function goUpdateConsoleCommands() {
   goUpdateCommand("consoleCmd_openURL");
   goUpdateCommand("consoleCmd_copyURL");
 }
   // ]]></script>
 
   <commandset id="editMenuCommands"/>
new file mode 100644
--- /dev/null
+++ b/devtools/docs/http-inspector.md
@@ -0,0 +1,167 @@
+# HTTP Inspector (aka XHR Spy)
+This document is intended as a description of HTTP Inspector feature allowing
+inline inspection of HTTP logs displayed in the Console panel. The documents
+focuses on internal architecture.
+
+For detailed feature description see the following doc
+(many screenshots included):
+https://docs.google.com/document/d/1zQniwU_dkt-VX1qY1Vp-SWxEVA4uFcDCrtH03tGoHHM/edit#
+
+_HTTP Inspector feature is available in the Console panel (for web developers)
+as well as in the Browser Console (for devtools and extension developers)._
+
+The current implementation is based on React (no XUL) and some of the existing
+components should also be used when porting the Network panel to HTML.
+
+The entire feature lives in `devtools/client/webconsole/net` directory.
+
+## General Description
+The entry point for HTTP log inspection is represented by an expand/toggle
+button displayed in front a log in the Console panel:
+
+[+] GET XHR http://example.com/test-request.php
+
+Clicking on the [+] button expands the log and shows a body with HTTP details
+right underneath. The info body is rendered by:
+`devtools/client/webconsole/net/components/net-info-body` component.
+
+HTTP info is divided into several tabs:
+
+* Headers: send and received HTTP headers
+* Params: URL parameters (query string)
+* Post: HTTP post body
+* Response: HTTP response body
+* Cookies: Sent and received cookies
+
+### Headers Tab
+`devtools/client/webconsole/net/components/headers-tab`
+
+This is the default active tab and it's responsible for rendering
+HTTP headers. There are several header groups displayed:
+
+* Response Headers
+* Requests Headers
+* Cached Headers (not implemented yet)
+
+Individual sections are expandable/collapsible.
+
+Rendering of the groups is done by `NetInfoGroup` and `NetInfoGroupList`
+components.
+
+### Params Tab
+`devtools/client/webconsole/net/components/params-tab`
+
+This tab is responsible for rendering URL parameters (query string)
+and it's available only if the URL has any parameters. Individual
+parameters are parsed and displayed as a list of name/value pairs.
+
+Rendering of the parameter list is done by `NetInfoParams` component.
+
+### Post Tab
+`devtools/client/webconsole/net/components/post-tab`
+
+This tab is responsible for rendering HTTP post body sent to the server.
+
+### Response Tab
+`devtools/client/webconsole/net/components/response-tab`
+
+This tab is responsible for rendering HTTP response body received from
+the server. There might be more than one section displaying the data
+depending on the current response mime-type.
+
+* Raw Data: This section is always displayed. It renders data in a raw
+form just like they are received from the server.
+* JSON: This section is available in case of JSON responses [1].
+It parses the response and displays it as an expandable tree.
+* Image: This section is available in case of image responses [2].
+The response is decoded and displayed as an image.
+* XML: this section is available in case of HTML/XML responses [3]
+The response is parsed using DOM parser and displayed as an XML markup.
+
+[1] List of JSON mime-types: `devtools/client/webconsole/net/utils/json`
+[2] List of Image mime-types: `devtools/client/webconsole/net/utils/json`
+[3] List of XML/HTML mime-types: `devtools/client/webconsole/net/utils/net`
+
+Response data are fetched using `LongStringClient`, so if data are bigger
+than defined limit (see `devtools/server/main.js - LONG_STRING_LENGTH)
+the user needs to manually require the rest (there is a link at the end
+of incomplete response body that allows this).
+
+The raw section is collapsed by default if there is another presentation
+of the data.
+
+### Cookies Tab
+`devtools/client/webconsole/net/components/cookies-tab`
+
+This tab is responsible for displaying HTTP cookies.
+There are two groups:
+
+* Request Cookies
+* Response Cookies
+
+Rendering of the groups is done by `NetInfoGroup` and `NetInfoGroupList`
+components. The tab is not presented if there are no cookies.
+
+## Architecture
+This sections describes internal architecture of HTTPi feature.
+
+### Main
+`devtools/client/webconsole/net/main`
+
+This is the main module of HTTPi. It represents the root module
+of the feature.
+
+The main responsibility of the module is handling network logs forwarded
+from webconsole.js. This modules creates one instance of `NetRequest`
+object for every `NetworkEvent` (one object for every HTTP request).
+
+### NetRequest
+`devtools/client/webconsole/net/net-request`
+
+This module represents `NetRequest` object. It's the internal representation
+of HTTP request and it keeps its state. All HTTP details fetched dynamically
+from the backend are stored in this object.
+
+This object is responsible for:
+* Adding a toggle button in Console UI (displayed in front of HTTP requests)
+* Listening for a click event on the toggle button.
+* Sending messages to web console client object to request HTTP details.
+* Refreshing the UI as HTTP details are coming from the overlay.
+
+Note that `NetRequest` is using a small helper object `DataProvider` for
+requesting HTTP details. `DataProvider` is the connection between `NetRequest`
+and the backend.
+
+### Data Provider
+`devtools/client/webconsole/net/data-provider`
+
+This module is using webconsole client object to get data from the backend.
+
+### Utils
+`devtools/client/webconsole/net/utils`
+
+There are also some utility modules implementing helper functions.
+The important thing is that these modules doesn't require any chrome
+privileges and are ready to run inside content scope.
+
+### Components
+* `NetInfoBody` Renders the entire HTTP details body displayed when the
+  user expands a network log.
+* `NetInfoGroup` Renders a group (a section within tab). For example,
+  Request Headers section in Headers tab corresponds to one group.
+* `NetInfoGroupList` List of groups. There might be more groups of data
+  within one tab. For example, the Headers tab has Requested and Response
+  headers groups.
+* `NetInfoParams` List of name-value pairs. It's used e.g. by the Headers
+  or Params tab.
+* `HeadersTab` Displays HTTP headers.
+* `PostTab` Displays HTTP posted body data.
+* `ParamsTab` Displays URL query string.
+* `ResponseTab` Displays HTTP response body data.
+* `CookiesTab` Displays cookies.
+* `Spinner` Represents a throbber displayed when the UI is waiting for
+  incoming data.
+* `SizeLimit` Represents a link that can be used to fetch the
+  rest of data from the backend (debugger server). Used for HTTP post
+  and response bodies.
+* `XmlView` Renders XML markup.