From 23402dfff6be52b4019a15a482aeb657cb72dca9 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Thu, 6 May 2021 04:26:41 -0700 Subject: [PATCH] Convert network plugin to Sandy Summary: converted the network plugin to use DataSource / DataTable. Restructured the storage to contain a single flat normalised object that will be much more efficient for rendering / filtering (as columns currently don't support nested keys yet, and lazy columns are a lot less flexible) lint errors and further `flipper` package usages will be cleaned up in the next diff to make sure this diff doesn't become too large. The rest of the plugin is converted in the next diff Reviewed By: nikoant Differential Revision: D27938581 fbshipit-source-id: 2e0e2ba75ef13d88304c6566d4519b121daa215b --- .../flipper-plugin/src/state/DataSource.tsx | 5 + .../network/ManageMockResponsePanel.tsx | 10 +- .../public/network/MockResponseDialog.tsx | 6 +- .../plugins/public/network/RequestDetails.tsx | 214 +++-- .../public/network/__tests__/chunks.node.tsx | 127 +-- .../network/__tests__/encoding.node.tsx | 6 +- .../__tests__/requestToCurlCommand.node.tsx | 51 +- desktop/plugins/public/network/chunks.tsx | 32 +- desktop/plugins/public/network/index.tsx | 841 +++++++----------- desktop/plugins/public/network/types.tsx | 40 +- desktop/plugins/public/network/utils.tsx | 38 +- docs/extending/sandy-migration.mdx | 1 + 12 files changed, 608 insertions(+), 763 deletions(-) diff --git a/desktop/flipper-plugin/src/state/DataSource.tsx b/desktop/flipper-plugin/src/state/DataSource.tsx index e69ee570a..721836611 100644 --- a/desktop/flipper-plugin/src/state/DataSource.tsx +++ b/desktop/flipper-plugin/src/state/DataSource.tsx @@ -137,6 +137,11 @@ export class DataSource< return unwrap(this._records[index]); } + public has(key: KEY_TYPE) { + this.assertKeySet(); + return this._recordsById.has(key); + } + public getById(key: KEY_TYPE) { this.assertKeySet(); return this._recordsById.get(key); diff --git a/desktop/plugins/public/network/ManageMockResponsePanel.tsx b/desktop/plugins/public/network/ManageMockResponsePanel.tsx index b203ff3ad..c385aebc3 100644 --- a/desktop/plugins/public/network/ManageMockResponsePanel.tsx +++ b/desktop/plugins/public/network/ManageMockResponsePanel.tsx @@ -17,7 +17,7 @@ import { Panel, } from 'flipper'; import React, {useContext, useState, useMemo, useEffect} from 'react'; -import {Route, Request, Response} from './types'; +import {Route, Requests} from './types'; import {MockResponseDetails} from './MockResponseDetails'; import {NetworkRouteContext} from './index'; import {RequestId} from './types'; @@ -27,8 +27,7 @@ import {NUX, Layout} from 'flipper-plugin'; type Props = { routes: {[id: string]: Route}; highlightedRows: Set | null | undefined; - requests: {[id: string]: Request}; - responses: {[id: string]: Response}; + requests: Requests; }; const ColumnSizes = {route: 'flex'}; @@ -224,7 +223,7 @@ export function ManageMockResponsePanel(props: Props) { Add Route + {isMockResponseSupported && ( + + )} + + } /> + {showMockResponseDialog ? ( + + {(onHide) => ( + { + onHide(); + instance.onCloseButtonPressed(); + }} + highlightedRows={ + new Set( + instance.tableManagerRef + .current!.getSelectedItems() + .map((r) => r.id), + ) + } + requests={instance.requests} + /> + )} + + ) : null} - - + + ); } -type NetworkTableProps = { - requests: {[id: string]: Request}; - responses: {[id: string]: Response}; - routes: {[id: string]: Route}; - clear: () => void; - copyRequestCurlCommand: () => void; - onRowHighlighted: (keys: TableHighlightedRows) => void; - highlightedRows: Set | null | undefined; - searchTerm: string; - onMockButtonPressed: () => void; - onCloseButtonPressed: () => void; - showMockResponseDialog: boolean; - isMockResponseSupported: boolean; -}; +const columns: DataTableColumn[] = [ + { + key: 'requestTime', + title: 'Request Time', + width: 120, + }, + { + key: 'responseTime', + title: 'Response Time', + width: 120, + visible: false, + }, + { + key: 'domain', + }, + { + key: 'url', + title: 'Full URL', + visible: false, + }, + { + key: 'method', + title: 'Method', + width: 70, + }, + { + key: 'status', + title: 'Status', + width: 70, + formatters: formatStatus, + align: 'right', + }, + { + key: 'responseLength', + title: 'Size', + width: 100, + formatters: formatBytes, + align: 'right', + }, + { + key: 'duration', + title: 'Time', + width: 100, + formatters: formatDuration, + align: 'right', + }, +]; -type NetworkTableState = { - sortedRows: TableRows; - routes: {[id: string]: Route}; - highlightedRows: Set | null | undefined; - requests: {[id: string]: Request}; - responses: {[id: string]: Response}; -}; - -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 getRowStyle(row: Request) { + return row.responseIsMock + ? mockingStyle + : row.status && row.status >= 400 && row.status < 600 + ? errorStyle + : undefined; } -function buildRow( - request: Request, - response: Response | null | undefined, -): TableBodyRow | null | undefined { - if (request == null) { - return null; +function copyRow(requests: Request[]): string { + const request = requests[0]; + if (!request || !request.url) { + return ''; } - if (request.url == null) { - return null; - } - let url: URL | undefined = undefined; - try { - url = new URL(request.url); - } catch (e) { - console.warn(`Failed to parse url: '${request.url}'`, e); - } - const domain = url ? url.host + url.pathname : ''; - const friendlyName = getHeaderValue(request.headers, 'X-FB-Friendly-Name'); - const style = response && response.isMock ? mockingStyle : undefined; - - const copyText = () => { - let copyText = `# HTTP request for ${domain} (ID: ${request.id}) + let copyText = `# HTTP request for ${request.domain} (ID: ${request.id}) ## Request HTTP ${request.method} ${request.url} - ${request.headers + ${request.requestHeaders .map( ({key, value}: {key: string; value: string}): string => `${key}: ${String(value)}`, ) .join('\n')}`; - // TODO: we want decoding only for non-binary data! See D23403095 - const requestData = request.data ? decodeBody(request) : null; - const responseData = - response && response.data ? decodeBody(response) : null; + // TODO: we want decoding only for non-binary data! See D23403095 + const requestData = request.requestData + ? decodeBody({ + headers: request.requestHeaders, + data: request.requestData, + }) + : null; + const responseData = request.responseData + ? decodeBody({ + headers: request.responseHeaders, + data: request.responseData, + }) + : null; - if (requestData) { - copyText += `\n\n${requestData}`; - } - - if (response) { - copyText += ` + if (requestData) { + copyText += `\n\n${requestData}`; + } + if (request.status) { + 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 copyText; - }; - - 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, - getSearchContent: copyText, - highlightOnHover: true, - style: style, - }; -} - -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); - } - } - } - } - if (props.responses !== nextProps.responses) { - // new or updated response - const resIds = Object.keys(nextProps.responses).filter( - (responseId: RequestId) => - props.responses[responseId] !== nextProps.responses[responseId], - ); - for (const resId of resIds) { - 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; - } - } - } - } + HTTP ${request.status} ${request.reason} + ${ + request.responseHeaders + ?.map( + ({key, value}: {key: string; value: string}): string => + `${key}: ${String(value)}`, + ) + .join('\n') ?? '' + }`; } - rows.sort( - (a: TableBodyRow, b: TableBodyRow) => - (a.sortKey as number) - (b.sortKey as number), - ); - - return { - sortedRows: rows, - routes: nextProps.routes, - highlightedRows: nextProps.highlightedRows, - requests: props.requests, - responses: props.responses, - }; + if (responseData) { + copyText += `\n\n${responseData}`; + } + return copyText; } function Sidebar() { const instance = usePlugin(plugin); - const requests = useValue(instance.requests); - const responses = useValue(instance.responses); - const selectedIds = useValue(instance.selectedIds); + const selectedId = useValue(instance.selectedId); const detailBodyFormat = useValue(instance.detailBodyFormat); - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null; - if (!selectedId) { - return null; - } - const requestWithId = requests[selectedId]; - if (!requestWithId) { + const request = instance.requests.getById(selectedId!); + if (!request) { return null; } @@ -822,8 +749,7 @@ function Sidebar() { @@ -831,189 +757,24 @@ function Sidebar() { ); } -class NetworkTable extends PureComponent { - 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 { - 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 ( - <> - - - - {this.props.isMockResponseSupported && ( - - )} - - } - /> - - {this.props.showMockResponseDialog ? ( - - {(onHide) => ( - { - onHide(); - this.props.onCloseButtonPressed(); - }} - highlightedRows={this.state.highlightedRows} - requests={this.state.requests} - responses={this.state.responses} - /> - )} - - ) : null} - - ); - } -} - 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 = ; - } - +function formatStatus(status: number | undefined) { + if (typeof status === 'number' && status >= 400 && status < 600) { return ( - - {glyph} - {children} - + <> + + {status} + ); } + return status; } -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 ( - - {duration != null ? duration.toLocaleString() + 'ms' : ''} - - ); - } -} - -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 {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; - } +function formatDuration(duration: number | undefined) { + if (typeof duration === 'number') return duration + 'ms'; + return ''; } diff --git a/desktop/plugins/public/network/types.tsx b/desktop/plugins/public/network/types.tsx index 4a5ed3710..1a64d1ae7 100644 --- a/desktop/plugins/public/network/types.tsx +++ b/desktop/plugins/public/network/types.tsx @@ -7,20 +7,44 @@ * @format */ +import {DataSource} from 'flipper-plugin'; import {AnyNestedObject} from 'protobufjs'; export type RequestId = string; -export type Request = { +export interface Request { + id: RequestId; + // request + requestTime: Date; + method: string; + url: string; + domain: string; + requestHeaders: Array
; + requestData?: string; + // response + responseTime?: Date; + status?: number; + reason?: string; + responseHeaders?: Array
; + responseData?: string; + responseLength?: number; + responseIsMock?: boolean; + duration?: number; + insights?: Insights; +} + +export type Requests = DataSource; + +export type RequestInfo = { id: RequestId; timestamp: number; method: string; - url: string; + url?: string; headers: Array
; data: string | null | undefined; }; -export type Response = { +export type ResponseInfo = { id: RequestId; timestamp: number; status: number; @@ -95,9 +119,9 @@ export type MockRoute = { enabled: boolean; }; -export type PartialResponses = { - [id: string]: { - initialResponse?: Response; - followupChunks: {[id: number]: string}; - }; +export type PartialResponse = { + initialResponse?: ResponseInfo; + followupChunks: {[id: number]: string}; }; + +export type PartialResponses = Record; diff --git a/desktop/plugins/public/network/utils.tsx b/desktop/plugins/public/network/utils.tsx index a6e238142..36c1fe1c9 100644 --- a/desktop/plugins/public/network/utils.tsx +++ b/desktop/plugins/public/network/utils.tsx @@ -8,10 +8,16 @@ */ import pako from 'pako'; -import {Request, Response, Header} from './types'; +import {Request, Header, ResponseInfo} from './types'; import {Base64} from 'js-base64'; -export function getHeaderValue(headers: Array
, key: string): string { +export function getHeaderValue( + headers: Array
| undefined, + key: string, +): string { + if (!headers) { + return ''; + } for (const header of headers) { if (header.key.toLowerCase() === key.toLowerCase()) { return header.value; @@ -20,7 +26,10 @@ export function getHeaderValue(headers: Array
, key: string): string { return ''; } -export function decodeBody(container: Request | Response): string { +export function decodeBody(container: { + headers?: Array
; + data: string | null | undefined; +}): string { if (!container.data) { return ''; } @@ -59,16 +68,21 @@ export function decodeBody(container: Request | Response): string { } } -export function convertRequestToCurlCommand(request: Request): string { +export function convertRequestToCurlCommand( + request: Pick, +): string { let command: string = `curl -v -X ${request.method}`; command += ` ${escapedString(request.url)}`; // Add headers - request.headers.forEach((header: Header) => { + request.requestHeaders.forEach((header: Header) => { const headerStr = `${header.key}: ${header.value}`; command += ` -H ${escapedString(headerStr)}`; }); // Add body. TODO: we only want this for non-binary data! See D23403095 - const body = decodeBody(request); + const body = decodeBody({ + headers: request.requestHeaders, + data: request.requestData, + }); if (body) { command += ` -d ${escapedString(body)}`; } @@ -101,3 +115,15 @@ function escapedString(str: string) { // Simply use singly quoted string. return "'" + str + "'"; } + +export function getResponseLength(request: ResponseInfo): number { + const lengthString = request.headers + ? getHeaderValue(request.headers, 'content-length') + : undefined; + if (lengthString) { + return parseInt(lengthString, 10); + } else if (request.data) { + return Buffer.byteLength(request.data, 'base64'); + } + return 0; +} diff --git a/docs/extending/sandy-migration.mdx b/docs/extending/sandy-migration.mdx index b55a84fab..abe88a498 100644 --- a/docs/extending/sandy-migration.mdx +++ b/docs/extending/sandy-migration.mdx @@ -78,6 +78,7 @@ This step is completed if the plugin follows the next `plugin` / `component` str * Similarly `yarn watch` can be used to run the unit tests in watch mode. Use the `p` key to filter for your specific plugin if `jest` doesn't do so automatically. * Example of migrating the network plugin to use Sandy APIs. D24108772 / [Github commit](https://github.com/facebook/flipper/commit/fdde2761ef054e44f399c846a2eae6baba03861e) * Example of migrating the example plugin to use Sandy APIs. D22308265 / [Github commit](https://github.com/facebook/flipper/commit/babc88e472612c66901d21d289bd217ef28ee385#diff-a145be72bb13a4675dcc8cbac5e55abcd9a542cc92f5c781bd7d3749f13676fc) +* Other plugins that can be check for inspiration are the Logs and Network plugins. * These steps typically does not involve change much the UI or touch other files than `index.tsx`. Typically, the root component needs to be changed, but most other components can remain as is. However, if a ManagedTable is used (see the next section), it might be easier to already convert the table in this step. * Sandy has first class support for unit testing your plugin and mocking device interactions. Please do set up unit tests per documentation linked above! * If the original plugin definition contained `state`, it is recommended to create one new state atoms (`createState`) per field in the original `state`, rather than having one big atom.