Initial commit 🎉
fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2 Co-authored-by: Sebastian McKenzie <sebmck@fb.com> Co-authored-by: John Knox <jknox@fb.com> Co-authored-by: Emil Sjölander <emilsj@fb.com> Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
72
src/plugins/index.js
Normal file
72
src/plugins/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {GK} from 'sonar';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import * as Sonar from 'sonar';
|
||||
import {SonarBasePlugin} from '../plugin.js';
|
||||
|
||||
const plugins = new Map();
|
||||
|
||||
// expose Sonar and exact globally for dynamically loaded plugins
|
||||
window.React = React;
|
||||
window.ReactDOM = ReactDOM;
|
||||
window.Sonar = Sonar;
|
||||
|
||||
const addIfNotAdded = plugin => {
|
||||
if (!plugins.has(plugin.name)) {
|
||||
plugins.set(plugin.name, plugin);
|
||||
}
|
||||
};
|
||||
|
||||
let disabledPlugins = [];
|
||||
try {
|
||||
disabledPlugins =
|
||||
JSON.parse(window.process.env.CONFIG || '{}').disabledPlugins || [];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Load dynamic plugins
|
||||
try {
|
||||
JSON.parse(window.process.env.PLUGINS || '[]').forEach(addIfNotAdded);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// DefaultPlugins that are included in the bundle.
|
||||
// List of defaultPlugins is written at build time
|
||||
let bundledPlugins = [];
|
||||
try {
|
||||
bundledPlugins = window.electronRequire('./defaultPlugins/index.json');
|
||||
} catch (e) {}
|
||||
bundledPlugins
|
||||
.map(plugin => ({
|
||||
...plugin,
|
||||
out: './' + plugin.out,
|
||||
}))
|
||||
.forEach(addIfNotAdded);
|
||||
|
||||
export default Array.from(plugins.values())
|
||||
.map(plugin => {
|
||||
if (
|
||||
(plugin.gatekeeper && !GK.get(plugin.gatekeeper)) ||
|
||||
disabledPlugins.indexOf(plugin.name) > -1
|
||||
) {
|
||||
return null;
|
||||
} else {
|
||||
try {
|
||||
return window.electronRequire(plugin.out);
|
||||
} catch (e) {
|
||||
console.error(plugin, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(plugin => plugin.prototype instanceof SonarBasePlugin);
|
||||
576
src/plugins/layout/index.js
Normal file
576
src/plugins/layout/index.js
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {ElementID, Element, ElementSearchResultSet} from 'sonar';
|
||||
import {
|
||||
colors,
|
||||
Glyph,
|
||||
GK,
|
||||
FlexRow,
|
||||
FlexColumn,
|
||||
Toolbar,
|
||||
SonarPlugin,
|
||||
ElementsInspector,
|
||||
InspectorSidebar,
|
||||
LoadingIndicator,
|
||||
styled,
|
||||
Component,
|
||||
SearchBox,
|
||||
SearchInput,
|
||||
SearchIcon,
|
||||
} from 'sonar';
|
||||
|
||||
// $FlowFixMe
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
export type InspectorState = {|
|
||||
initialised: boolean,
|
||||
selected: ?ElementID,
|
||||
root: ?ElementID,
|
||||
elements: {[key: ElementID]: Element},
|
||||
isSearchActive: boolean,
|
||||
searchResults: ?ElementSearchResultSet,
|
||||
outstandingSearchQuery: ?string,
|
||||
|};
|
||||
|
||||
type SelectElementArgs = {|
|
||||
key: ElementID,
|
||||
|};
|
||||
|
||||
type ExpandElementArgs = {|
|
||||
key: ElementID,
|
||||
expand: boolean,
|
||||
|};
|
||||
|
||||
type ExpandElementsArgs = {|
|
||||
elements: Array<ElementID>,
|
||||
|};
|
||||
|
||||
type UpdateElementsArgs = {|
|
||||
elements: Array<$Shape<Element>>,
|
||||
|};
|
||||
|
||||
type SetRootArgs = {|
|
||||
root: ElementID,
|
||||
|};
|
||||
|
||||
type GetNodesResult = {|
|
||||
elements: Array<Element>,
|
||||
|};
|
||||
|
||||
type SearchResultTree = {|
|
||||
id: string,
|
||||
isMatch: Boolean,
|
||||
children: ?Array<SearchResultTree>,
|
||||
element: Element,
|
||||
|};
|
||||
|
||||
const LoadingSpinner = LoadingIndicator.extends({
|
||||
marginRight: 4,
|
||||
marginLeft: 3,
|
||||
marginTop: -1,
|
||||
});
|
||||
|
||||
const Center = FlexRow.extends({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
const SearchIconContainer = styled.view({
|
||||
marginRight: 9,
|
||||
marginTop: -3,
|
||||
marginLeft: 4,
|
||||
});
|
||||
|
||||
class LayoutSearchInput extends Component<
|
||||
{
|
||||
onSubmit: string => void,
|
||||
},
|
||||
{
|
||||
value: string,
|
||||
},
|
||||
> {
|
||||
static TextInput = styled.textInput({
|
||||
width: '100%',
|
||||
marginLeft: 6,
|
||||
});
|
||||
|
||||
state = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
timer: TimeoutID;
|
||||
|
||||
onChange = (e: SyntheticInputEvent<>) => {
|
||||
clearTimeout(this.timer);
|
||||
this.setState({
|
||||
value: e.target.value,
|
||||
});
|
||||
this.timer = setTimeout(() => this.props.onSubmit(this.state.value), 200);
|
||||
};
|
||||
|
||||
onKeyDown = (e: SyntheticKeyboardEvent<>) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.props.onSubmit(this.state.value);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SearchInput
|
||||
placeholder={'Search'}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
value={this.state.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class Layout extends SonarPlugin<InspectorState> {
|
||||
static title = 'Layout';
|
||||
static id = 'Inspector';
|
||||
static icon = 'target';
|
||||
|
||||
state = {
|
||||
elements: {},
|
||||
initialised: false,
|
||||
isSearchActive: false,
|
||||
root: null,
|
||||
selected: null,
|
||||
searchResults: null,
|
||||
outstandingSearchQuery: null,
|
||||
};
|
||||
|
||||
reducers = {
|
||||
SelectElement(state: InspectorState, {key}: SelectElementArgs) {
|
||||
return {
|
||||
selected: key,
|
||||
};
|
||||
},
|
||||
|
||||
ExpandElement(state: InspectorState, {expand, key}: ExpandElementArgs) {
|
||||
return {
|
||||
elements: {
|
||||
...state.elements,
|
||||
[key]: {
|
||||
...state.elements[key],
|
||||
expanded: expand,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
ExpandElements(state: InspectorState, {elements}: ExpandElementsArgs) {
|
||||
const expandedSet = new Set(elements);
|
||||
const newState = {
|
||||
elements: {
|
||||
...state.elements,
|
||||
},
|
||||
};
|
||||
for (const key of Object.keys(state.elements)) {
|
||||
newState.elements[key] = {
|
||||
...newState.elements[key],
|
||||
expanded: expandedSet.has(key),
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
},
|
||||
|
||||
UpdateElements(state: InspectorState, {elements}: UpdateElementsArgs) {
|
||||
const updatedElements = state.elements;
|
||||
|
||||
for (const element of elements) {
|
||||
const current = updatedElements[element.id] || {};
|
||||
// $FlowFixMe
|
||||
updatedElements[element.id] = {
|
||||
...current,
|
||||
...element,
|
||||
};
|
||||
}
|
||||
|
||||
return {elements: updatedElements};
|
||||
},
|
||||
|
||||
SetRoot(state: InspectorState, {root}: SetRootArgs) {
|
||||
return {root};
|
||||
},
|
||||
|
||||
SetSearchActive(
|
||||
state: InspectorState,
|
||||
{isSearchActive}: {isSearchActive: boolean},
|
||||
) {
|
||||
return {isSearchActive};
|
||||
},
|
||||
};
|
||||
|
||||
search(query: string) {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
outstandingSearchQuery: query,
|
||||
});
|
||||
this.client
|
||||
.call('getSearchResults', {query: query})
|
||||
.then(response => this.displaySearchResults(response));
|
||||
}
|
||||
|
||||
executeCommand(command: string) {
|
||||
return this.client.call('executeCommand', {
|
||||
command: command,
|
||||
context: this.state.selected,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When opening the inspector for the first time, expand all elements that contain only 1 child
|
||||
* recursively.
|
||||
*/
|
||||
async performInitialExpand(element: Element): Promise<void> {
|
||||
if (!element.children.length) {
|
||||
// element has no children so we're as deep as we can be
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchAction({expand: true, key: element.id, type: 'ExpandElement'});
|
||||
|
||||
return this.getChildren(element.id).then((elements: Array<Element>) => {
|
||||
this.dispatchAction({elements, type: 'UpdateElements'});
|
||||
|
||||
if (element.children.length >= 2) {
|
||||
// element has two or more children so we can stop expanding
|
||||
return;
|
||||
}
|
||||
|
||||
return this.performInitialExpand(
|
||||
this.state.elements[element.children[0]],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
displaySearchResults({
|
||||
results,
|
||||
query,
|
||||
}: {
|
||||
results: SearchResultTree,
|
||||
query: string,
|
||||
}) {
|
||||
const elements = this.getElementsFromSearchResultTree(results);
|
||||
const idsToExpand = elements
|
||||
.filter(x => x.hasChildren)
|
||||
.map(x => x.element.id);
|
||||
|
||||
const finishedSearching = query === this.state.outstandingSearchQuery;
|
||||
|
||||
this.dispatchAction({
|
||||
elements: elements.map(x => x.element),
|
||||
type: 'UpdateElements',
|
||||
});
|
||||
this.dispatchAction({
|
||||
elements: idsToExpand,
|
||||
type: 'ExpandElements',
|
||||
});
|
||||
this.setState({
|
||||
searchResults: {
|
||||
matches: new Set(
|
||||
elements.filter(x => x.isMatch).map(x => x.element.id),
|
||||
),
|
||||
query: query,
|
||||
},
|
||||
outstandingSearchQuery: finishedSearching
|
||||
? null
|
||||
: this.state.outstandingSearchQuery,
|
||||
});
|
||||
}
|
||||
|
||||
getElementsFromSearchResultTree(tree: SearchResultTree) {
|
||||
if (!tree) {
|
||||
return [];
|
||||
}
|
||||
var elements = [
|
||||
{
|
||||
id: tree.id,
|
||||
isMatch: tree.isMatch,
|
||||
hasChildren: Boolean(tree.children),
|
||||
element: tree.element,
|
||||
},
|
||||
];
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
elements = elements.concat(this.getElementsFromSearchResultTree(child));
|
||||
}
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
init() {
|
||||
performance.mark('LayoutInspectorInitialize');
|
||||
this.client.call('getRoot').then((element: Element) => {
|
||||
this.dispatchAction({elements: [element], type: 'UpdateElements'});
|
||||
this.dispatchAction({root: element.id, type: 'SetRoot'});
|
||||
this.performInitialExpand(element).then(() => {
|
||||
this.app.logger.trackTimeSince('LayoutInspectorInitialize');
|
||||
this.setState({initialised: true});
|
||||
});
|
||||
});
|
||||
|
||||
this.client.subscribe(
|
||||
'invalidate',
|
||||
({nodes}: {nodes: Array<{id: ElementID}>}) => {
|
||||
this.invalidate(nodes.map(node => node.id)).then(
|
||||
(elements: Array<Element>) => {
|
||||
this.dispatchAction({elements, type: 'UpdateElements'});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
this.client.subscribe('select', ({path}: {path: Array<ElementID>}) => {
|
||||
this.getNodesAndDirectChildren(path).then((elements: Array<Element>) => {
|
||||
const selected = path[path.length - 1];
|
||||
|
||||
this.dispatchAction({elements, type: 'UpdateElements'});
|
||||
this.dispatchAction({key: selected, type: 'SelectElement'});
|
||||
this.dispatchAction({isSearchActive: false, type: 'SetSearchActive'});
|
||||
|
||||
for (const key of path) {
|
||||
this.dispatchAction({expand: true, key, type: 'ExpandElement'});
|
||||
}
|
||||
|
||||
this.client.send('setHighlighted', {id: selected});
|
||||
this.client.send('setSearchActive', {active: false});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(ids: Array<ElementID>): Promise<Array<Element>> {
|
||||
if (ids.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return this.getNodes(ids, true).then((elements: Array<Element>) => {
|
||||
const children = elements
|
||||
.filter(element => {
|
||||
const prev = this.state.elements[element.id];
|
||||
return prev && prev.expanded;
|
||||
})
|
||||
.map(element => element.children)
|
||||
.reduce((acc, val) => acc.concat(val), []);
|
||||
|
||||
return Promise.all([elements, this.invalidate(children)]).then(arr => {
|
||||
return arr.reduce((acc, val) => acc.concat(val), []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getNodesAndDirectChildren(ids: Array<ElementID>): Promise<Array<Element>> {
|
||||
return this.getNodes(ids, false).then((elements: Array<Element>) => {
|
||||
const children = elements
|
||||
.map(element => element.children)
|
||||
.reduce((acc, val) => acc.concat(val), []);
|
||||
|
||||
return Promise.all([elements, this.getNodes(children, false)]).then(
|
||||
arr => {
|
||||
return arr.reduce((acc, val) => acc.concat(val), []);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getChildren(key: ElementID): Promise<Array<Element>> {
|
||||
return this.getNodes(this.state.elements[key].children, false);
|
||||
}
|
||||
|
||||
getNodes(
|
||||
ids: Array<ElementID> = [],
|
||||
force: boolean,
|
||||
): Promise<Array<Element>> {
|
||||
if (!force) {
|
||||
ids = ids.filter(id => {
|
||||
return this.state.elements[id] === undefined;
|
||||
});
|
||||
}
|
||||
|
||||
if (ids.length > 0) {
|
||||
performance.mark('LayoutInspectorGetNodes');
|
||||
return this.client
|
||||
.call('getNodes', {ids})
|
||||
.then(({elements}: GetNodesResult) => {
|
||||
this.app.logger.trackTimeSince('LayoutInspectorGetNodes');
|
||||
return Promise.resolve(elements);
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
isExpanded(key: ElementID): boolean {
|
||||
return this.state.elements[key].expanded;
|
||||
}
|
||||
|
||||
expandElement = (key: ElementID): Promise<Array<Element>> => {
|
||||
const expand = !this.isExpanded(key);
|
||||
return this.setElementExpanded(key, expand);
|
||||
};
|
||||
|
||||
setElementExpanded = (
|
||||
key: ElementID,
|
||||
expand: boolean,
|
||||
): Promise<Array<Element>> => {
|
||||
this.dispatchAction({expand, key, type: 'ExpandElement'});
|
||||
performance.mark('LayoutInspectorExpandElement');
|
||||
if (expand) {
|
||||
return this.getChildren(key).then((elements: Array<Element>) => {
|
||||
this.app.logger.trackTimeSince('LayoutInspectorExpandElement');
|
||||
this.dispatchAction({elements, type: 'UpdateElements'});
|
||||
return Promise.resolve(elements);
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
};
|
||||
|
||||
deepExpandElement = async (key: ElementID) => {
|
||||
const expand = !this.isExpanded(key);
|
||||
if (!expand) {
|
||||
// we never deep unexpand
|
||||
return this.setElementExpanded(key, false);
|
||||
}
|
||||
|
||||
// queue of keys to open
|
||||
const keys = [key];
|
||||
|
||||
// amount of elements we've expanded, we stop at 100 just to be safe
|
||||
let count = 0;
|
||||
|
||||
while (keys.length && count < 100) {
|
||||
const key = keys.shift();
|
||||
|
||||
// expand current element
|
||||
const children = await this.setElementExpanded(key, true);
|
||||
|
||||
// and add it's children to the queue
|
||||
for (const child of children) {
|
||||
keys.push(child.id);
|
||||
}
|
||||
|
||||
count++;
|
||||
}
|
||||
};
|
||||
|
||||
onElementExpanded = (key: ElementID, deep: boolean) => {
|
||||
if (deep) {
|
||||
this.deepExpandElement(key);
|
||||
} else {
|
||||
this.expandElement(key);
|
||||
}
|
||||
this.app.logger.track('usage', 'layout:element-expanded', {
|
||||
id: key,
|
||||
deep: deep,
|
||||
});
|
||||
};
|
||||
|
||||
onFindClick = () => {
|
||||
const isSearchActive = !this.state.isSearchActive;
|
||||
this.dispatchAction({isSearchActive, type: 'SetSearchActive'});
|
||||
this.client.send('setSearchActive', {active: isSearchActive});
|
||||
};
|
||||
|
||||
onElementSelected = debounce((key: ElementID) => {
|
||||
this.dispatchAction({key, type: 'SelectElement'});
|
||||
this.client.send('setHighlighted', {id: key});
|
||||
this.getNodes([key], true).then((elements: Array<Element>) => {
|
||||
this.dispatchAction({elements, type: 'UpdateElements'});
|
||||
});
|
||||
});
|
||||
|
||||
onElementHovered = debounce((key: ?ElementID) => {
|
||||
this.client.send('setHighlighted', {id: key});
|
||||
});
|
||||
|
||||
onDataValueChanged = (path: Array<string>, value: any) => {
|
||||
this.client.send('setData', {id: this.state.selected, path, value});
|
||||
this.app.logger.track('usage', 'layout:value-changed', {
|
||||
id: this.state.selected,
|
||||
value: value,
|
||||
path: path,
|
||||
});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
return this.state.selected != null ? (
|
||||
<InspectorSidebar
|
||||
element={this.state.elements[this.state.selected]}
|
||||
onValueChanged={this.onDataValueChanged}
|
||||
client={this.client}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
initialised,
|
||||
selected,
|
||||
root,
|
||||
elements,
|
||||
isSearchActive,
|
||||
outstandingSearchQuery,
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<FlexColumn fill={true}>
|
||||
<Toolbar>
|
||||
<SearchIconContainer
|
||||
onClick={this.onFindClick}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
title="Select an element on the device to inspect it">
|
||||
<Glyph
|
||||
name="target"
|
||||
size={16}
|
||||
color={
|
||||
isSearchActive
|
||||
? colors.macOSTitleBarIconSelected
|
||||
: colors.macOSTitleBarIconActive
|
||||
}
|
||||
/>
|
||||
</SearchIconContainer>
|
||||
{GK.get('sonar_layout_search') && (
|
||||
<SearchBox tabIndex={-1}>
|
||||
<SearchIcon
|
||||
name="magnifying-glass"
|
||||
color={colors.macOSTitleBarIcon}
|
||||
size={16}
|
||||
/>
|
||||
<LayoutSearchInput onSubmit={this.search.bind(this)} />
|
||||
{outstandingSearchQuery && <LoadingSpinner size={16} />}
|
||||
</SearchBox>
|
||||
)}
|
||||
</Toolbar>
|
||||
<FlexRow fill={true}>
|
||||
{initialised ? (
|
||||
<ElementsInspector
|
||||
onElementSelected={this.onElementSelected}
|
||||
onElementHovered={this.onElementHovered}
|
||||
onElementExpanded={this.onElementExpanded}
|
||||
onValueChanged={this.onDataValueChanged}
|
||||
selected={selected}
|
||||
searchResults={this.state.searchResults}
|
||||
root={root}
|
||||
elements={elements}
|
||||
/>
|
||||
) : (
|
||||
<Center fill={true}>
|
||||
<LoadingIndicator />
|
||||
</Center>
|
||||
)}
|
||||
</FlexRow>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
9
src/plugins/layout/package.json
Normal file
9
src/plugins/layout/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "sonar-plugin-layout",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
}
|
||||
}
|
||||
7
src/plugins/layout/yarn.lock
Normal file
7
src/plugins/layout/yarn.lock
Normal file
@@ -0,0 +1,7 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
539
src/plugins/network/RequestDetails.js
Normal file
539
src/plugins/network/RequestDetails.js
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
// $FlowFixMe
|
||||
import pako from 'pako';
|
||||
import type {Request, Response, Header} from './index.js';
|
||||
|
||||
import {
|
||||
Component,
|
||||
FlexColumn,
|
||||
ManagedTable,
|
||||
ManagedDataInspector,
|
||||
Text,
|
||||
Panel,
|
||||
styled,
|
||||
colors,
|
||||
} from 'sonar';
|
||||
import {getHeaderValue} from './index.js';
|
||||
|
||||
import querystring from 'querystring';
|
||||
|
||||
const WrappingText = Text.extends({
|
||||
wordWrap: 'break-word',
|
||||
width: '100%',
|
||||
lineHeight: '125%',
|
||||
padding: '3px 0',
|
||||
});
|
||||
|
||||
const KeyValueColumnSizes = {
|
||||
key: '30%',
|
||||
value: 'flex',
|
||||
};
|
||||
|
||||
const KeyValueColumns = {
|
||||
key: {
|
||||
value: 'Key',
|
||||
resizable: false,
|
||||
},
|
||||
value: {
|
||||
value: 'Value',
|
||||
resizable: false,
|
||||
},
|
||||
};
|
||||
|
||||
type RequestDetailsProps = {
|
||||
request: Request,
|
||||
response: ?Response,
|
||||
};
|
||||
|
||||
function decodeBody(container: Request | Response): string {
|
||||
if (!container.data) {
|
||||
return '';
|
||||
}
|
||||
const b64Decoded = atob(container.data);
|
||||
const encodingHeader = container.headers.find(
|
||||
header => header.key === 'Content-Encoding',
|
||||
);
|
||||
|
||||
return encodingHeader && encodingHeader.value === 'gzip'
|
||||
? decompress(b64Decoded)
|
||||
: b64Decoded;
|
||||
}
|
||||
|
||||
function decompress(body: string): string {
|
||||
const charArray = body.split('').map(x => x.charCodeAt(0));
|
||||
|
||||
const byteArray = new Uint8Array(charArray);
|
||||
|
||||
let data;
|
||||
try {
|
||||
if (body) {
|
||||
data = pako.inflate(byteArray);
|
||||
} else {
|
||||
return body;
|
||||
}
|
||||
} catch (e) {
|
||||
// Sometimes Content-Encoding is 'gzip' but the body is already decompressed.
|
||||
// Assume this is the case when decompression fails.
|
||||
return body;
|
||||
}
|
||||
|
||||
return String.fromCharCode.apply(null, new Uint8Array(data));
|
||||
}
|
||||
|
||||
export default class RequestDetails extends Component<RequestDetailsProps> {
|
||||
static Container = FlexColumn.extends({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
urlColumns = (url: URL) => {
|
||||
return [
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Full URL</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.href}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.href,
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Host</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.host}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.host,
|
||||
key: 'host',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Path</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.pathname}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.pathname,
|
||||
key: 'path',
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
key: {value: <WrappingText>Query String</WrappingText>},
|
||||
value: {
|
||||
value: <WrappingText>{url.search}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: url.search,
|
||||
key: 'query',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel heading={'Request'} floating={false} padded={false}>
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={this.urlColumns(url)}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
{url.search ? (
|
||||
<Panel
|
||||
heading={'Request Query Parameters'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<QueryInspector queryParams={url.searchParams} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.headers.length > 0 ? (
|
||||
<Panel heading={'Request Headers'} floating={false} padded={false}>
|
||||
<HeaderInspector headers={request.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.data != null ? (
|
||||
<Panel heading={'Request Body'} floating={false}>
|
||||
<RequestBodyInspector request={request} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{response
|
||||
? [
|
||||
response.headers.length > 0 ? (
|
||||
<Panel
|
||||
heading={'Response Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={response.headers} />
|
||||
</Panel>
|
||||
) : null,
|
||||
<Panel heading={'Response Body'} floating={false}>
|
||||
<ResponseBodyInspector request={request} response={response} />
|
||||
</Panel>,
|
||||
]
|
||||
: null}
|
||||
</RequestDetails.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows = [];
|
||||
for (const kv of queryParams.entries()) {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{kv[0]}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{kv[1]}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: kv[1],
|
||||
key: kv[0],
|
||||
});
|
||||
}
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
type HeaderInspectorProps = {
|
||||
headers: Array<Header>,
|
||||
};
|
||||
|
||||
type HeaderInspectorState = {
|
||||
computedHeaders: Object,
|
||||
};
|
||||
|
||||
class HeaderInspector extends Component<
|
||||
HeaderInspectorProps,
|
||||
HeaderInspectorState,
|
||||
> {
|
||||
render() {
|
||||
const computedHeaders = this.props.headers.reduce((sum, header) => {
|
||||
return {...sum, [header.key]: header.value};
|
||||
}, {});
|
||||
|
||||
const rows = [];
|
||||
for (const key in computedHeaders) {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{computedHeaders[key]}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: computedHeaders[key],
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.view({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any,
|
||||
formatResponse?: (request: Request, response: Response) => any,
|
||||
};
|
||||
|
||||
class RequestBodyInspector extends Component<{
|
||||
request: Request,
|
||||
}> {
|
||||
render() {
|
||||
const {request} = this.props;
|
||||
let component;
|
||||
try {
|
||||
for (const formatter of BodyFormatters) {
|
||||
if (formatter.formatRequest) {
|
||||
component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (component == null && request.data != null) {
|
||||
component = <Text>{decodeBody(request)}</Text>;
|
||||
}
|
||||
|
||||
if (component == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBodyInspector extends Component<{
|
||||
response: Response,
|
||||
request: Request,
|
||||
}> {
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
|
||||
let component;
|
||||
try {
|
||||
for (const formatter of BodyFormatters) {
|
||||
if (formatter.formatResponse) {
|
||||
component = formatter.formatResponse(request, response);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
component = component || <Text>{decodeBody(response)}</Text>;
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
const MediaContainer = FlexColumn.extends({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
type ImageWithSizeProps = {
|
||||
src: string,
|
||||
};
|
||||
|
||||
type ImageWithSizeState = {
|
||||
width: number,
|
||||
height: number,
|
||||
};
|
||||
|
||||
class ImageWithSize extends Component<ImageWithSizeProps, ImageWithSizeState> {
|
||||
static Image = styled.image({
|
||||
objectFit: 'scale-down',
|
||||
maxWidth: 500,
|
||||
maxHeight: 500,
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = Text.extends({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const image = new Image();
|
||||
image.src = this.props.src;
|
||||
image.onload = () => {
|
||||
image.width;
|
||||
image.height;
|
||||
this.setState({
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<ImageWithSize.Image src={this.props.src} />
|
||||
<ImageWithSize.Text>
|
||||
{this.state.width} x {this.state.height}
|
||||
</ImageWithSize.Text>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFormatter {
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
if (getHeaderValue(response.headers, 'content-type').startsWith('image')) {
|
||||
return <ImageWithSize src={request.url} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class VideoFormatter {
|
||||
static Video = styled.customHTMLTag('video', {
|
||||
maxWidth: 500,
|
||||
maxHeight: 500,
|
||||
});
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
const contentType = getHeaderValue(response.headers, 'content-type');
|
||||
if (contentType.startsWith('video')) {
|
||||
return (
|
||||
<MediaContainer>
|
||||
<VideoFormatter.Video controls={true}>
|
||||
<source src={request.url} type={contentType} />
|
||||
</VideoFormatter.Video>
|
||||
</MediaContainer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class JSONFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
return this.format(
|
||||
decodeBody(request),
|
||||
getHeaderValue(request.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
formatResponse = (request: Request, response: Response) => {
|
||||
return this.format(
|
||||
decodeBody(response),
|
||||
getHeaderValue(response.headers, 'content-type'),
|
||||
);
|
||||
};
|
||||
|
||||
format = (body: string, contentType: string) => {
|
||||
if (
|
||||
contentType.startsWith('application/json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const roots = body.split('\n');
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={roots.map(json => JSON.parse(json))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class LogEventFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('logging_client_event') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (data.message) {
|
||||
data.message = JSON.parse(data.message);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLBatchFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphqlbatch') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (data.queries) {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphql') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (data.variables) {
|
||||
data.variables = JSON.parse(data.variables);
|
||||
}
|
||||
if (data.query_params) {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class FormUrlencodedFormatter {
|
||||
formatRequest = (request: Request) => {
|
||||
const contentType = getHeaderValue(request.headers, 'content-type');
|
||||
if (contentType.startsWith('application/x-www-form-urlencoded')) {
|
||||
return (
|
||||
<ManagedDataInspector
|
||||
expandRoot={true}
|
||||
data={querystring.parse(decodeBody(request))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const BodyFormatters: Array<BodyFormatter> = [
|
||||
new ImageFormatter(),
|
||||
new VideoFormatter(),
|
||||
new LogEventFormatter(),
|
||||
new GraphQLBatchFormatter(),
|
||||
new GraphQLFormatter(),
|
||||
new JSONFormatter(),
|
||||
new FormUrlencodedFormatter(),
|
||||
];
|
||||
411
src/plugins/network/index.js
Normal file
411
src/plugins/network/index.js
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import type {TableHighlightedRows, TableRows} from 'sonar';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
FlexColumn,
|
||||
Button,
|
||||
Text,
|
||||
Glyph,
|
||||
colors,
|
||||
PureComponent,
|
||||
} from 'sonar';
|
||||
|
||||
import {SonarPlugin, SearchableTable} from 'sonar';
|
||||
import RequestDetails from './RequestDetails.js';
|
||||
|
||||
import {URL} from 'url';
|
||||
// $FlowFixMe
|
||||
import sortBy from 'lodash.sortby';
|
||||
|
||||
type RequestId = string;
|
||||
|
||||
type State = {|
|
||||
requests: {[id: RequestId]: Request},
|
||||
responses: {[id: RequestId]: Response},
|
||||
selectedIds: Array<RequestId>,
|
||||
|};
|
||||
|
||||
export type Request = {|
|
||||
id: RequestId,
|
||||
timestamp: number,
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Array<Header>,
|
||||
data: ?string,
|
||||
|};
|
||||
|
||||
export type Response = {|
|
||||
id: RequestId,
|
||||
timestamp: number,
|
||||
status: number,
|
||||
reason: string,
|
||||
headers: Array<Header>,
|
||||
data: ?string,
|
||||
|};
|
||||
|
||||
export type Header = {|
|
||||
key: string,
|
||||
value: string,
|
||||
|};
|
||||
|
||||
const COLUMN_SIZE = {
|
||||
domain: 'flex',
|
||||
method: 100,
|
||||
status: 70,
|
||||
size: 100,
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const COLUMNS = {
|
||||
domain: {
|
||||
value: 'Domain',
|
||||
},
|
||||
method: {
|
||||
value: 'Method',
|
||||
},
|
||||
status: {
|
||||
value: 'Status',
|
||||
},
|
||||
size: {
|
||||
value: 'Size',
|
||||
},
|
||||
duration: {
|
||||
value: 'Duration',
|
||||
},
|
||||
};
|
||||
|
||||
export function getHeaderValue(headers: Array<Header>, key: string) {
|
||||
for (const header of headers) {
|
||||
if (header.key.toLowerCase() === key.toLowerCase()) {
|
||||
return header.value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function formatBytes(count: number): string {
|
||||
if (count > 1024 * 1024) {
|
||||
return (count / (1024.0 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
if (count > 1024) {
|
||||
return (count / 1024.0).toFixed(1) + ' kB';
|
||||
}
|
||||
return count + ' B';
|
||||
}
|
||||
|
||||
const TextEllipsis = Text.extends({
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: '18px',
|
||||
paddingTop: 4,
|
||||
});
|
||||
|
||||
export default class extends SonarPlugin<State> {
|
||||
static title = 'Network';
|
||||
static id = 'Network';
|
||||
static icon = 'internet';
|
||||
static keyboardActions = ['clear'];
|
||||
|
||||
onKeyboardAction = (action: string) => {
|
||||
if (action === 'clear') {
|
||||
this.clearLogs();
|
||||
}
|
||||
};
|
||||
|
||||
state = {
|
||||
requests: {},
|
||||
responses: {},
|
||||
selectedIds: [],
|
||||
};
|
||||
|
||||
init() {
|
||||
this.client.subscribe('newRequest', (request: Request) => {
|
||||
this.dispatchAction({request, type: 'NewRequest'});
|
||||
});
|
||||
this.client.subscribe('newResponse', (response: Response) => {
|
||||
this.dispatchAction({response, type: 'NewResponse'});
|
||||
});
|
||||
}
|
||||
|
||||
reducers = {
|
||||
NewRequest(state: State, {request}: {request: Request}) {
|
||||
return {
|
||||
requests: {...state.requests, [request.id]: request},
|
||||
responses: state.responses,
|
||||
};
|
||||
},
|
||||
|
||||
NewResponse(state: State, {response}: {response: Response}) {
|
||||
return {
|
||||
requests: state.requests,
|
||||
responses: {...state.responses, [response.id]: response},
|
||||
};
|
||||
},
|
||||
|
||||
Clear(state: State) {
|
||||
return {
|
||||
requests: {},
|
||||
responses: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
onRowHighlighted = (selectedIds: Array<RequestId>) =>
|
||||
this.setState({selectedIds});
|
||||
|
||||
clearLogs = () => {
|
||||
this.setState({selectedIds: []});
|
||||
this.dispatchAction({type: 'Clear'});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
const {selectedIds, requests, responses} = this.state;
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;
|
||||
|
||||
return selectedId != null ? (
|
||||
<RequestDetails
|
||||
key={selectedId}
|
||||
request={requests[selectedId]}
|
||||
response={responses[selectedId]}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FlexColumn fill={true}>
|
||||
<NetworkTable
|
||||
requests={this.state.requests}
|
||||
responses={this.state.responses}
|
||||
clear={this.clearLogs}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
/>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type NetworkTableProps = {|
|
||||
requests: {[id: RequestId]: Request},
|
||||
responses: {[id: RequestId]: Response},
|
||||
clear: () => void,
|
||||
onRowHighlighted: (keys: TableHighlightedRows) => void,
|
||||
|};
|
||||
|
||||
type NetworkTableState = {|
|
||||
sortedRows: TableRows,
|
||||
|};
|
||||
|
||||
class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
|
||||
static ContextMenu = ContextMenu.extends({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
state = {
|
||||
sortedRows: [],
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: NetworkTableProps) {
|
||||
if (Object.keys(nextProps.requests).length === 0) {
|
||||
// cleared
|
||||
this.setState({sortedRows: []});
|
||||
} else if (this.props.requests !== nextProps.requests) {
|
||||
// new request
|
||||
for (const requestId in nextProps.requests) {
|
||||
if (this.props.requests[requestId] == null) {
|
||||
this.buildRow(nextProps.requests[requestId], null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (this.props.responses !== nextProps.responses) {
|
||||
// new response
|
||||
for (const responseId in nextProps.responses) {
|
||||
if (this.props.responses[responseId] == null) {
|
||||
this.buildRow(
|
||||
nextProps.requests[responseId],
|
||||
nextProps.responses[responseId],
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildRow(request: Request, response: ?Response) {
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const domain = url.host + url.pathname;
|
||||
const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name');
|
||||
|
||||
const newRow = {
|
||||
columns: {
|
||||
domain: {
|
||||
value: (
|
||||
<TextEllipsis>{friendlyName ? friendlyName : domain}</TextEllipsis>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
method: {
|
||||
value: <TextEllipsis>{request.method}</TextEllipsis>,
|
||||
isFilterable: true,
|
||||
},
|
||||
status: {
|
||||
value: (
|
||||
<StatusColumn>
|
||||
{response ? response.status : undefined}
|
||||
</StatusColumn>
|
||||
),
|
||||
isFilterable: true,
|
||||
},
|
||||
size: {
|
||||
value: <SizeColumn response={response ? response : undefined} />,
|
||||
},
|
||||
duration: {
|
||||
value: <DurationColumn request={request} response={response} />,
|
||||
},
|
||||
},
|
||||
key: request.id,
|
||||
filterValue: `${request.method} ${request.url}`,
|
||||
sortKey: request.timestamp,
|
||||
copyText: request.url,
|
||||
highlightOnHover: true,
|
||||
};
|
||||
|
||||
let rows;
|
||||
if (response == null) {
|
||||
rows = [...this.state.sortedRows, newRow];
|
||||
} else {
|
||||
const index = this.state.sortedRows.findIndex(r => r.key === request.id);
|
||||
if (index > -1) {
|
||||
rows = [...this.state.sortedRows];
|
||||
rows[index] = newRow;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
sortedRows: sortBy(rows, x => x.sortKey),
|
||||
});
|
||||
}
|
||||
|
||||
contextMenuItems = [
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Clear all',
|
||||
click: this.props.clear,
|
||||
},
|
||||
];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<NetworkTable.ContextMenu items={this.contextMenuItems}>
|
||||
<SearchableTable
|
||||
virtual={true}
|
||||
multiline={false}
|
||||
multiHighlight={true}
|
||||
stickyBottom={true}
|
||||
floating={false}
|
||||
columnSizes={COLUMN_SIZE}
|
||||
columns={COLUMNS}
|
||||
rows={this.state.sortedRows}
|
||||
onRowHighlighted={this.props.onRowHighlighted}
|
||||
rowLineHeight={26}
|
||||
zebra={false}
|
||||
actions={<Button onClick={this.props.clear}>Clear Table</Button>}
|
||||
/>
|
||||
</NetworkTable.ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = Glyph.extends({
|
||||
marginTop: -3,
|
||||
marginRight: 3,
|
||||
});
|
||||
|
||||
class StatusColumn extends PureComponent<{
|
||||
children?: number,
|
||||
}> {
|
||||
render() {
|
||||
const {children} = this.props;
|
||||
let glyph;
|
||||
|
||||
if (children != null && children >= 400 && children < 600) {
|
||||
glyph = <Icon name="stop-solid" color={colors.red} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEllipsis>
|
||||
{glyph}
|
||||
{children}
|
||||
</TextEllipsis>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DurationColumn extends PureComponent<{
|
||||
request: Request,
|
||||
response: ?Response,
|
||||
}> {
|
||||
static Text = Text.extends({
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
const duration = response
|
||||
? response.timestamp - request.timestamp
|
||||
: undefined;
|
||||
return (
|
||||
<DurationColumn.Text selectable={false}>
|
||||
{duration != null ? duration.toLocaleString() + 'ms' : ''}
|
||||
</DurationColumn.Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SizeColumn extends PureComponent<{
|
||||
response: ?Response,
|
||||
}> {
|
||||
static Text = Text.extends({
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
render() {
|
||||
const {response} = this.props;
|
||||
if (response) {
|
||||
const text = formatBytes(this.getResponseLength(response));
|
||||
return <SizeColumn.Text>{text}</SizeColumn.Text>;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
getResponseLength(response) {
|
||||
let length = 0;
|
||||
const lengthString = response.headers
|
||||
? getHeaderValue(response.headers, 'content-length')
|
||||
: undefined;
|
||||
if (lengthString != null && lengthString != '') {
|
||||
length = parseInt(lengthString, 10);
|
||||
} else if (response.data) {
|
||||
length = atob(response.data).length;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
}
|
||||
10
src/plugins/network/package.json
Normal file
10
src/plugins/network/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "sonar-plugin-network",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash.sortby": "^4.7.0",
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
}
|
||||
11
src/plugins/network/yarn.lock
Normal file
11
src/plugins/network/yarn.lock
Normal file
@@ -0,0 +1,11 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
lodash.sortby@^4.7.0:
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||
|
||||
pako@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
|
||||
Reference in New Issue
Block a user