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:
Daniel Büchele
2018-04-13 08:38:06 -07:00
committed by Daniel Buchele
commit fbbf8cf16b
659 changed files with 87130 additions and 0 deletions

View 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(),
];

View 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;
}
}

View 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"
}
}

View 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"