Summary: See previous diffs, same change as with analytics, don't stringify when creating the rows, but rather provide a closure to do that lazily, which reduces CPU (and mem) usage. Note that this implicitly assumes the captures variables are immutable, as otherwise the semantics would change. But than again, if those variables are changed in the future, copyText will reflect their latest rather than initial state, which is better anyway. Reviewed By: nikoant Differential Revision: D21931748 fbshipit-source-id: 62325dd25953bab3a4e2e9a0d5dea3b3a7787bae
820 lines
20 KiB
TypeScript
820 lines
20 KiB
TypeScript
/**
|
|
* 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${
|
|
response.isMock ? ' (Mocked)' : ''
|
|
}`}
|
|
floating={false}
|
|
padded={false}>
|
|
<HeaderInspector headers={response.headers} />
|
|
</Panel>
|
|
) : null}
|
|
<Panel
|
|
key={'responsebody'}
|
|
heading={`Response Body${response.isMock ? ' (Mocked)' : ''}`}
|
|
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;
|
|
}
|
|
}
|