Files
flipper/src/plugins/network/index.js
John Knox d48f93edea Add method field to persistedStateReducer
Summary:
When sending a message from the mobile side, you call something like:

`send(method: string, params: Object)`

But when receiving it in the reducer, you only get the params. Adding method so you can distinguish them.

Reviewed By: passy

Differential Revision: D10447890

fbshipit-source-id: f1fe925e82355866f86b322ecd3c72c604ae86af
2018-10-19 03:37:59 -07:00

452 lines
10 KiB
JavaScript

/**
* 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, TableBodyRow} from 'flipper';
import {
ContextMenu,
FlexColumn,
Button,
Text,
Glyph,
colors,
PureComponent,
DetailSidebar,
styled,
SearchableTable,
FlipperPlugin,
} from 'flipper';
import RequestDetails from './RequestDetails.js';
import {URL} from 'url';
import type {Notification} from '../../plugin';
type RequestId = string;
type PersistedState = {|
requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: Response},
|};
type State = {|
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): 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 = styled(Text)({
overflowX: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
lineHeight: '18px',
paddingTop: 4,
});
export default class extends FlipperPlugin<State, *, PersistedState> {
static title = 'Network';
static id = 'Network';
static icon = 'internet';
static keyboardActions = ['clear'];
static subscribed = [];
static persistedStateReducer = (
persistedState: PersistedState,
method: string,
data: Request | Response,
): PersistedState => {
const dataType: 'requests' | 'responses' = data.url
? 'requests'
: 'responses';
if (persistedState) {
return {
[dataType]: {
...persistedState[dataType],
[data.id]: data,
},
};
}
return {
[dataType]: {
[data.id]: data,
},
};
};
static getActiveNotifications = (
persistedState: PersistedState,
): Array<Notification> => {
const responses = persistedState ? persistedState.responses || [] : [];
return (
// $FlowFixMe Object.values returns Array<mixed>, but we know it is Array<Response>
(Object.values(responses): Array<Response>)
// Show error messages for all status codes indicating a client or server error
.filter((response: Response) => response.status >= 400)
.map((response: Response) => ({
id: response.id,
title: `HTTP ${response.status}: Network request failed`,
message: `Request to "${persistedState.requests[response.id]?.url ||
'(URL missing)'}" failed. ${response.reason}`,
severity: 'error',
timestamp: response.timestamp,
category: response.status,
action: response.id,
}))
);
};
onKeyboardAction = (action: string) => {
if (action === 'clear') {
this.clearLogs();
}
};
state = {
selectedIds: [],
};
onRowHighlighted = (selectedIds: Array<RequestId>) =>
this.setState({selectedIds});
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;
return selectedId != null ? (
<RequestDetails
key={selectedId}
request={requests[selectedId]}
response={responses[selectedId]}
/>
) : null;
};
render() {
const {requests, responses} = this.props.persistedState;
return (
<FlexColumn fill={true}>
<NetworkTable
requests={requests || {}}
responses={responses || {}}
clear={this.clearLogs}
onRowHighlighted={this.onRowHighlighted}
/>
<DetailSidebar>{this.renderSidebar()}</DetailSidebar>
</FlexColumn>
);
}
}
type NetworkTableProps = {
requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: Response},
clear: () => void,
onRowHighlighted: (keys: TableHighlightedRows) => void,
};
type NetworkTableState = {|
sortedRows: TableRows,
|};
function buildRow(request: Request, response: ?Response): ?TableBodyRow {
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');
return {
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,
};
}
function calculateState(
props: {
requests: {[id: RequestId]: Request},
responses: {[id: RequestId]: 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 in nextProps.requests) {
if (props.requests[requestId] == null) {
const newRow = buildRow(
nextProps.requests[requestId],
nextProps.responses[requestId],
);
if (newRow) {
rows.push(newRow);
}
}
}
} else if (props.responses !== nextProps.responses) {
// new response
for (const responseId in nextProps.responses) {
if (props.responses[responseId] == null) {
const newRow = buildRow(
nextProps.requests[responseId],
nextProps.responses[responseId],
);
const index = rows.findIndex(
r => r.key === nextProps.requests[responseId]?.id,
);
if (index > -1 && newRow) {
rows[index] = newRow;
}
break;
}
}
}
rows.sort((a, b) => (String(a.sortKey) > String(b.sortKey) ? 1 : -1));
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,
);
}
componentWillReceiveProps(nextProps: NetworkTableProps) {
this.setState(calculateState(this.props, nextProps, this.state.sortedRows));
}
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 = 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-solid" color={colors.red} />;
}
return (
<TextEllipsis>
{glyph}
{children}
</TextEllipsis>
);
}
}
class DurationColumn extends PureComponent<{
request: Request,
response: ?Response,
}> {
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,
}> {
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) {
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;
}
}