/** * 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, |}; export type Request = {| id: RequestId, timestamp: number, method: string, url: string, headers: Array
, data: ?string, |}; export type Response = {| id: RequestId, timestamp: number, status: number, reason: string, headers: Array
, 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
, 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 { 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 => { const responses = persistedState ? persistedState.responses || [] : []; return ( // $FlowFixMe Object.values returns Array, but we know it is Array (Object.values(responses): Array) // 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) => 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 ? ( ) : null; }; render() { const {requests, responses} = this.props.persistedState; return ( {this.renderSidebar()} ); } } 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: ( {friendlyName ? friendlyName : domain} ), isFilterable: true, }, method: { value: {request.method}, isFilterable: true, }, status: { value: ( {response ? response.status : undefined} ), isFilterable: true, }, size: { value: , }, duration: { value: , }, }, 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 { 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 ( Clear Table} /> ); } } 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 = ; } return ( {glyph} {children} ); } } 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 ( {duration != null ? duration.toLocaleString() + 'ms' : ''} ); } } 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 {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; } }