/** * 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, MetricType, } from 'flipper'; import {padStart} from 'lodash'; import { ContextMenu, FlexColumn, Button, Text, Glyph, colors, PureComponent, DetailSidebar, styled, SearchableTable, FlipperPlugin, } from 'flipper'; import type {Request, RequestId, Response} from './types.js'; import { convertRequestToCurlCommand, getHeaderValue, decodeBody, } from './utils.js'; import RequestDetails from './RequestDetails.js'; import {clipboard} from 'electron'; import {URL} from 'url'; import type {Notification} from '../../plugin.tsx'; type PersistedState = {| requests: {[id: RequestId]: Request}, responses: {[id: RequestId]: Response}, |}; type State = {| selectedIds: Array, |}; 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 { static keyboardActions = ['clear']; static subscribed = []; static defaultPersistedState = { requests: {}, responses: {}, }; static metricsReducer = ( persistedState: PersistedState, ): Promise => { const failures = Object.keys(persistedState.responses).reduce(function( previous, key, ) { return previous + (persistedState.responses[key].status >= 400); }, 0); return Promise.resolve({NUMBER_NETWORK_FAILURES: failures}); }; static persistedStateReducer = ( persistedState: PersistedState, method: string, data: Request | Response, ): PersistedState => { const dataType: 'requests' | 'responses' = data.url ? 'requests' : 'responses'; return { ...persistedState, [dataType]: { ...persistedState[dataType], [data.id]: data, }, }; }; static getActiveNotifications = ( persistedState: PersistedState, ): Array => { const responses = persistedState ? persistedState.responses || [] : []; // $FlowFixMe Object.values returns Array, but we know it is Array const r: Array = 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) => ({ 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: `HTTP${response.status}`, action: response.id, })) ); }; onKeyboardAction = (action: string) => { if (action === 'clear') { this.clearLogs(); } }; state = { selectedIds: this.props.deepLinkPayload ? [this.props.deepLinkPayload] : [], }; onRowHighlighted = (selectedIds: Array) => 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]]; 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; 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, copyRequestCurlCommand: () => void, onRowHighlighted: (keys: TableHighlightedRows) => void, highlightedRows: ?Set, }; 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): ?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'); let copyText = `# HTTP request for ${domain} (ID: ${request.id}) ## Request HTTP ${request.method} ${request.url} ${request.headers .map(({key, value}) => `${key}: ${String(value)}`) .join('\n')}`; if (request.data) { copyText += `\n\n${decodeBody(request)}`; } if (response) { copyText += ` ## Response HTTP ${response.status} ${response.reason} ${response.headers .map(({key, value}) => `${key}: ${String(value)}`) .join('\n')}`; } if (response) { copyText += `\n\n${decodeBody(response)}`; } return { columns: { requestTimestamp: { value: ( {formatTimestamp(request.timestamp)} ), }, responseTimestamp: { value: ( {response && formatTimestamp(response.timestamp)} ), }, 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, 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) => Number(a.sortKey) - Number(b.sortKey)); 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() { 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 ( 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 = Buffer.byteLength(response.data, 'base64'); } return length; } }