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.