Bug 1555631 - WebSocket frame payload preview. r=Honza,nchevobbe

Support WebSocket frame payload preview.

Differential Revision: https://phabricator.services.mozilla.com/D38271

--HG--
extra : moz-landing-system : lando
This commit is contained in:
tanhengyeow 2019-07-19 13:25:30 +00:00
Родитель 27b7855a32
Коммит 8f2fe89432
15 изменённых файлов: 354 добавлений и 148 удалений

Просмотреть файл

@ -237,6 +237,7 @@ devtools.jar:
content/netmonitor/src/assets/icons/shield.svg (netmonitor/src/assets/icons/shield.svg)
content/netmonitor/index.html (netmonitor/index.html)
content/netmonitor/src/assets/styles/StatusCode.css (netmonitor/src/assets/styles/StatusCode.css)
content/netmonitor/src/assets/styles/websockets.css (netmonitor/src/assets/styles/websockets.css)
# Application panel
content/application/index.html (application/index.html)

Просмотреть файл

@ -731,6 +731,10 @@ netmonitor.ws.type.received=Received
# %1$S is the formatted hour-minutes-seconds, %2$S is the milliseconds (zero-padded)
netmonitor.ws.time.format=%1$S.%2$S
# LOCALIZATION NOTE (netmonitor.ws.rawData.header): This is the label displayed
# in the messages panel identifying the raw data.
netmonitor.ws.rawData.header=Raw Data (%S)
# LOCALIZATION NOTE (netmonitor.tab.headers): This is the label displayed
# in the network details pane identifying the headers tab.
netmonitor.tab.headers=Headers

Просмотреть файл

@ -10,10 +10,6 @@
overflow-x: hidden;
}
.ws-frame-list-empty-notice {
width: 100%;
}
.empty-notice-element {
padding-top: 12px;
padding-left: 12px;
@ -516,43 +512,6 @@
color: var(--theme-selection-color);
}
/* Frame type icon in the WebSockets Time column */
.ws-frames-list-type-icon {
display: inline-block;
/* align bottom of image 4px below the text baseline
this tends to give a better result than "middle" */
vertical-align: -4px;
-moz-context-properties: fill;
fill: currentColor;
}
.ws-frames-list-type-icon-sent {
color: var(--green-70);
}
.theme-dark .ws-frames-list-type-icon-sent {
color: var(--green-50);
}
.ws-frames-list-type-icon-received {
color: var(--red-60);
transform: scaleY(-1);
}
.theme-dark .ws-frames-list-type-icon-received {
color: var(--red-40);
}
.ws-frame-list-item.selected .ws-frames-list-type-icon {
color: inherit;
}
/* Use lining numbers so that seconds and milliseconds align */
.ws-frames-list-time {
font-variant-numeric: tabular-nums;
}
/* Responsive web design support */
@media (max-width: 700px) {

Просмотреть файл

@ -5,6 +5,7 @@
@import "resource://devtools/client/shared/components/SidebarToggle.css";
@import "resource://devtools/client/shared/components/splitter/SplitBox.css";
@import "resource://devtools/client/shared/components/tree/TreeView.css";
@import "resource://devtools/client/shared/components/Accordion.css";
@import "resource://devtools/client/shared/components/tabs/Tabs.css";
@import "chrome://devtools/skin/components-frame.css";
@import "chrome://devtools/content/shared/sourceeditor/codemirror/lib/codemirror.css";
@ -21,6 +22,7 @@
@import "chrome://devtools/content/netmonitor/src/assets/styles/StatisticsPanel.css";
@import "chrome://devtools/content/netmonitor/src/assets/styles/CustomRequestPanel.css";
@import "chrome://devtools/content/netmonitor/src/assets/styles/StatusCode.css";
@import "chrome://devtools/content/netmonitor/src/assets/styles/websockets.css";
/* General */

Просмотреть файл

@ -0,0 +1,70 @@
/* Empty notice */
#messages-panel .ws-frame-list-empty-notice {
width: 100%;
}
/* Frame type icon in the WebSockets Time column */
#messages-panel .ws-frames-list-type-icon {
display: inline-block;
/* align bottom of image 4px below the text baseline
this tends to give a better result than "middle" */
vertical-align: -4px;
-moz-context-properties: fill;
fill: currentColor;
}
#messages-panel .ws-frames-list-type-icon-sent {
color: var(--green-70);
}
#messages-panel .theme-dark .ws-frames-list-type-icon-sent {
color: var(--green-50);
}
#messages-panel .ws-frames-list-type-icon-received {
color: var(--red-60);
transform: scaleY(-1);
}
#messages-panel .theme-dark .ws-frames-list-type-icon-received {
color: var(--red-40);
}
#messages-panel .ws-frame-list-item.selected .ws-frames-list-type-icon {
color: inherit;
}
/* Use lining numbers so that seconds and milliseconds align */
#messages-panel .ws-frames-list-time {
font-variant-numeric: tabular-nums;
}
/* Styles related to the Accordion items in the FramePayload component */
#messages-panel .ws-frame-payload {
width: 100%;
}
#messages-panel .ws-frame-rawData-payload {
white-space: pre-wrap;
overflow-wrap: break-word;
padding: 4px 8px;
padding-inline-start: 20px;
font-family: var(--monospace-font-family);
font-size: var(--theme-code-font-size);
line-height: calc(15/11);
}
#messages-panel #ws-frame-rawData-header,
#messages-panel #ws-frame-formattedData-header {
width: 100%
}
/* Styles related to JSONPreview */
#messages-panel .treeTable .objectBox {
white-space: normal;
overflow-wrap: break-word;
}

Просмотреть файл

@ -0,0 +1,83 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const {
Component,
createFactory,
} = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
// Components
const TreeView = createFactory(
require("devtools/client/shared/components/tree/TreeView")
);
loader.lazyGetter(this, "Rep", function() {
return require("devtools/client/shared/components/reps/reps").REPS.Rep;
});
loader.lazyGetter(this, "MODE", function() {
return require("devtools/client/shared/components/reps/reps").MODE;
});
/**
* Shows JSON in a custom tree format.
*/
class JSONPreview extends Component {
static get propTypes() {
return {
// Custom value renderer
renderValue: PropTypes.func,
cropLimit: PropTypes.number,
};
}
constructor(props) {
super(props);
this.renderValueWithRep = this.renderValueWithRep.bind(this);
}
renderValueWithRep(props) {
const { member } = props;
// Hide strings with following conditions
// 1. this row is a togglable section and content is object ('cause it shouldn't hide
// when string or number)
// 2. the `value` object has a `value` property, only happened in Cookies panel
// Put 2 here to not dup this method
if (
(member.level === 0 && member.type === "object") ||
(typeof member.value === "object" && member.value && member.value.value)
) {
return null;
}
return Rep(
Object.assign(props, {
// FIXME: A workaround for the issue in StringRep
// Force StringRep to crop the text every time
member: Object.assign({}, member, { open: false }),
mode: MODE.TINY,
cropLimit: this.props.cropLimit,
noGrip: true,
})
);
}
render() {
return dom.div(
{
className: "tree-container",
},
TreeView({
...this.props,
renderValue: this.props.renderValue || this.renderValueWithRep,
})
);
}
}
module.exports = JSONPreview;

Просмотреть файл

@ -19,7 +19,9 @@ const { FILTER_SEARCH_DELAY } = require("../constants");
// Components
const TreeViewClass = require("devtools/client/shared/components/tree/TreeView");
const PropertiesViewContextMenu = require("../widgets/PropertiesViewContextMenu");
const TreeView = createFactory(TreeViewClass);
const JSONPreview = createFactory(
require("devtools/client/netmonitor/src/components/JSONPreview")
);
loader.lazyGetter(this, "SearchBox", function() {
return createFactory(require("devtools/client/shared/components/SearchBox"));
@ -36,13 +38,6 @@ loader.lazyGetter(this, "HTMLPreview", function() {
return createFactory(require("./HtmlPreview"));
});
loader.lazyGetter(this, "Rep", function() {
return require("devtools/client/shared/components/reps/reps").REPS.Rep;
});
loader.lazyGetter(this, "MODE", function() {
return require("devtools/client/shared/components/reps/reps").MODE;
});
const { div, tr, td, pre } = dom;
const AUTO_EXPAND_MAX_LEVEL = 7;
const AUTO_EXPAND_MAX_NODES = 50;
@ -95,7 +90,6 @@ class PropertiesView extends Component {
this.getRowClass = this.getRowClass.bind(this);
this.onFilter = this.onFilter.bind(this);
this.renderRowWithExtras = this.renderRowWithExtras.bind(this);
this.renderValueWithRep = this.renderValueWithRep.bind(this);
this.shouldRenderSearchBox = this.shouldRenderSearchBox.bind(this);
this.updateFilterText = this.updateFilterText.bind(this);
}
@ -176,33 +170,6 @@ class PropertiesView extends Component {
}
}
renderValueWithRep(props) {
const { member } = props;
// Hide strings with following conditions
// 1. this row is a togglable section and content is object ('cause it shouldn't hide
// when string or number)
// 2. the `value` object has a `value` property, only happened in Cookies panel
// Put 2 here to not dup this method
if (
(member.level === 0 && member.type === "object") ||
(typeof member.value === "object" && member.value && member.value.value)
) {
return null;
}
return Rep(
Object.assign(props, {
// FIXME: A workaround for the issue in StringRep
// Force StringRep to crop the text every time
member: Object.assign({}, member, { open: false }),
mode: MODE.TINY,
cropLimit: this.props.cropLimit,
noGrip: true,
})
);
}
sectionIsSearchable(object, section) {
return !(
object[section][EDITOR_CONFIG_ID] || object[section][HTML_PREVIEW_ID]
@ -251,34 +218,31 @@ class PropertiesView extends Component {
placeholder: filterPlaceHolder,
})
),
div(
{ className: "tree-container" },
TreeView({
object,
provider,
columns: [
{
id: "value",
width: "100%",
},
],
decorator: decorator || {
getRowClass: rowObject => this.getRowClass(rowObject, sectionNames),
JSONPreview({
object,
provider,
columns: [
{
id: "value",
width: "100%",
},
enableInput,
expandableStrings,
useQuotes: false,
expandedNodes: TreeViewClass.getExpandedNodes(object, {
maxLevel: AUTO_EXPAND_MAX_LEVEL,
maxNodes: AUTO_EXPAND_MAX_NODES,
}),
onFilter: props => this.onFilter(props, sectionNames),
renderRow: renderRow || this.renderRowWithExtras,
renderValue: renderValue || this.renderValueWithRep,
openLink,
onContextMenuRow: this.onContextMenuRow,
})
)
],
decorator: decorator || {
getRowClass: rowObject => this.getRowClass(rowObject, sectionNames),
},
enableInput,
expandableStrings,
useQuotes: false,
expandedNodes: TreeViewClass.getExpandedNodes(object, {
maxLevel: AUTO_EXPAND_MAX_LEVEL,
maxNodes: AUTO_EXPAND_MAX_NODES,
}),
onFilter: props => this.onFilter(props, sectionNames),
renderRow: renderRow || this.renderRowWithExtras,
renderValue,
openLink,
onContextMenuRow: this.onContextMenuRow,
})
);
}
}

Просмотреть файл

@ -17,6 +17,7 @@ const {
fetchNetworkUpdatePacket,
formDataURI,
getUrlBaseName,
isJSON,
} = require("../utils/request-utils");
const { Filters } = require("../utils/filter-predicates");
@ -85,23 +86,6 @@ class ResponsePanel extends Component {
});
}
/**
* This method checks that the response is base64 encoded by
* comparing these 2 values:
* 1. The original response
* 2. The value of doing a base64 decode on the
* response and then base64 encoding the result.
* If the values are different or an error is thrown,
* the method will return false.
*/
isBase64(response) {
try {
return btoa(atob(response)) == response;
} catch (err) {
return false;
}
}
/**
* Handle json, which we tentatively identify by checking the
* MIME type for "json" after any word boundary. This works
@ -111,12 +95,11 @@ class ResponsePanel extends Component {
* it's json or not, to handle responses incorrectly labeled
* as text/plain instead.
*/
isJSON(mimeType, response) {
handleJSONResponse(mimeType, response) {
const limit = Services.prefs.getIntPref(
"devtools.netmonitor.responseBodyLimit"
);
const { request } = this.props;
let json, error;
// Check if the response has been truncated, in which case no parse should
// be attempted.
@ -126,19 +109,7 @@ class ResponsePanel extends Component {
return result;
}
try {
json = JSON.parse(response);
} catch (err) {
if (this.isBase64(response)) {
try {
json = JSON.parse(atob(response));
} catch (err64) {
error = err;
}
} else {
error = err;
}
}
let { json, error } = isJSON(response);
if (/\bjson/.test(mimeType) || json) {
// Extract the actual json substring in case this might be a "JSONP".
@ -226,7 +197,8 @@ class ResponsePanel extends Component {
}
// Display Properties View
const { json, jsonpCallback, error } = this.isJSON(mimeType, text) || {};
const { json, jsonpCallback, error } =
this.handleJSONResponse(mimeType, text) || {};
const object = {};
let sectionName;

Просмотреть файл

@ -14,6 +14,7 @@ DevToolsModules(
'DropHarHandler.js',
'HeadersPanel.js',
'HtmlPreview.js',
'JSONPreview.js',
'MonitorPanel.js',
'NetworkDetailsPanel.js',
'ParamsPanel.js',

Просмотреть файл

@ -4,11 +4,32 @@
"use strict";
const { Component } = require("devtools/client/shared/vendor/react");
const {
Component,
createFactory,
} = require("devtools/client/shared/vendor/react");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
const { div } = dom;
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const { getFramePayload } = require("../../utils/request-utils");
const { L10N } = require("devtools/client/netmonitor/src/utils/l10n.js");
const {
getFramePayload,
isJSON,
} = require("devtools/client/netmonitor/src/utils/request-utils.js");
const {
getFormattedSize,
} = require("devtools/client/netmonitor/src/utils/format-utils.js");
// Components
const Accordion = createFactory(
require("devtools/client/shared/components/Accordion")
);
const RawData = createFactory(require("./RawData"));
loader.lazyGetter(this, "JSONPreview", function() {
return createFactory(
require("devtools/client/netmonitor/src/components/JSONPreview")
);
});
/**
* Shows the full payload of a WebSocket frame.
@ -27,6 +48,8 @@ class FramePayload extends Component {
this.state = {
payload: "",
isFormattedData: false,
formattedData: {},
};
}
@ -34,8 +57,11 @@ class FramePayload extends Component {
const { selectedFrame, connector } = this.props;
getFramePayload(selectedFrame.payload, connector.getLongString).then(
payload => {
const { json } = isJSON(payload);
this.setState({
payload,
isFormattedData: !!json,
formattedData: json,
});
}
);
@ -45,15 +71,55 @@ class FramePayload extends Component {
const { selectedFrame, connector } = nextProps;
getFramePayload(selectedFrame.payload, connector.getLongString).then(
payload => {
const { json } = isJSON(payload);
this.setState({
payload,
isFormattedData: !!json,
formattedData: json,
});
}
);
}
render() {
return div({ className: "ws-frame-payload" }, this.state.payload);
const items = [
{
className: "rawData",
component: RawData({ payload: this.state.payload }),
header: L10N.getFormatStrWithNumbers(
"netmonitor.ws.rawData.header",
getFormattedSize(this.state.payload.length)
),
labelledby: "ws-frame-rawData-header",
opened: true,
},
];
if (this.state.isFormattedData) {
items.push({
className: "formattedData",
component: JSONPreview({
object: this.state.formattedData,
columns: [
{
id: "value",
width: "100%",
},
],
}),
header: `JSON (${getFormattedSize(this.state.payload.length)})`,
labelledby: "ws-frame-formattedData-header",
opened: true,
});
}
return div(
{
className: "ws-frame-payload",
},
Accordion({
items,
})
);
}
}

Просмотреть файл

@ -0,0 +1,32 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { Component } = require("devtools/client/shared/vendor/react");
const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
const dom = require("devtools/client/shared/vendor/react-dom-factories");
/**
* Shows raw payload of a WebSocket frame.
*/
class RawData extends Component {
static get propTypes() {
return {
payload: PropTypes.string.isRequired,
};
}
render() {
const { payload } = this.props;
return dom.div(
{
className: "ws-frame-rawData-payload",
},
payload
);
}
}
module.exports = RawData;

Просмотреть файл

@ -17,7 +17,7 @@ const {
connect,
} = require("devtools/client/shared/redux/visibility-handler-connect");
const Actions = require("../../actions/index");
const { findDOMNode } = require("devtools/client/shared/vendor/react-dom");
const {
getSelectedFrame,
isSelectedFrameVisible,
@ -71,6 +71,17 @@ class WebSocketsPanel extends Component {
}
}
componentWillUnmount() {
const { clientHeight } = findDOMNode(this.refs.endPanel) || {};
if (clientHeight) {
Services.prefs.setIntPref(
"devtools.netmonitor.ws.payload-preview-height",
clientHeight
);
}
}
// Reset the filter text
clearFilterText() {
if (this.searchboxRef) {
@ -81,9 +92,6 @@ class WebSocketsPanel extends Component {
render() {
const { frameDetailsOpen, connector, selectedFrame } = this.props;
const initialWidth = Services.prefs.getIntPref(
"devtools.netmonitor.ws.payload-preview-width"
);
const initialHeight = Services.prefs.getIntPref(
"devtools.netmonitor.ws.payload-preview-height"
);
@ -95,15 +103,15 @@ class WebSocketsPanel extends Component {
}),
SplitBox({
className: "devtools-responsive-container",
initialWidth: initialWidth,
initialHeight: initialHeight,
minSize: "50px",
maxSize: "50%",
maxSize: "80%",
splitterSize: frameDetailsOpen ? 1 : 0,
startPanel: FrameListContent({ connector }),
endPanel:
frameDetailsOpen &&
FramePayload({
ref: "endPanel",
connector,
selectedFrame,
}),

Просмотреть файл

@ -14,6 +14,7 @@ DevToolsModules(
'FrameListHeader.js',
'FrameListItem.js',
'FramePayload.js',
'RawData.js',
'StatusBar.js',
'Toolbar.js',
'WebSocketsPanel.js',

Просмотреть файл

@ -584,6 +584,49 @@ function processNetworkUpdates(update, request) {
return result;
}
/**
* This method checks that the response is base64 encoded by
* comparing these 2 values:
* 1. The original response
* 2. The value of doing a base64 decode on the
* response and then base64 encoding the result.
* If the values are different or an error is thrown,
* the method will return false.
*/
function isBase64(payload) {
try {
return btoa(atob(payload)) == payload;
} catch (err) {
return false;
}
}
/**
* Checks if the payload is of JSON type.
*/
function isJSON(payload) {
let json, error;
try {
json = JSON.parse(payload);
} catch (err) {
if (isBase64(payload)) {
try {
json = JSON.parse(atob(payload));
} catch (err64) {
error = err;
}
} else {
error = err;
}
}
return {
json,
error,
};
}
module.exports = {
decodeUnicodeBase64,
getFormDataSections,
@ -612,4 +655,5 @@ module.exports = {
processNetworkUpdates,
propertiesEqual,
ipToLong,
isJSON,
};

Просмотреть файл

@ -170,8 +170,7 @@ pref("devtools.netmonitor.visibleColumns",
);
pref("devtools.netmonitor.columnsData",
'[{"name":"status","minWidth":30,"width":5}, {"name":"method","minWidth":30,"width":5}, {"name":"domain","minWidth":30,"width":10}, {"name":"file","minWidth":30,"width":25}, {"name":"url","minWidth":30,"width":25}, {"name":"cause","minWidth":30,"width":10},{"name":"type","minWidth":30,"width":5},{"name":"transferred","minWidth":30,"width":10},{"name":"contentSize","minWidth":30,"width":5},{"name":"waterfall","minWidth":150,"width":25}]');
pref("devtools.netmonitor.ws.payload-preview-width", 550);
pref("devtools.netmonitor.ws.payload-preview-height", 450);
pref("devtools.netmonitor.ws.payload-preview-height", 128);
pref("devtools.netmonitor.response.ui.limit", 10240);