/** * Copyright (c) Microsoft Corporation. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { Entry } from '@trace/har'; import * as React from 'react'; import type { Boundaries } from '../geometry'; import './networkTab.css'; import { NetworkResourceDetails } from './networkResourceDetails'; import { bytesToString, msToString } from '@web/uiUtils'; import { PlaceholderPanel } from './placeholderPanel'; import type { MultiTraceModel } from './modelUtil'; import { GridView, type RenderedGridCell } from '@web/components/gridView'; import { SplitView } from '@web/components/splitView'; type NetworkTabModel = { resources: Entry[], }; type RenderedEntry = { name: { name: string, url: string }, method: string, status: { code: number, text: string }, contentType: string, duration: number, size: number, start: number, route: string, resource: Entry, }; type ColumnName = keyof RenderedEntry; type Sorting = { by: ColumnName, negate: boolean}; const NetworkGridView = GridView; export function useNetworkTabModel(model: MultiTraceModel | undefined, selectedTime: Boundaries | undefined): NetworkTabModel { const resources = React.useMemo(() => { const resources = model?.resources || []; const filtered = resources.filter(resource => { if (!selectedTime) return true; return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum); }); return filtered; }, [model, selectedTime]); return { resources }; } export const NetworkTab: React.FunctionComponent<{ boundaries: Boundaries, networkModel: NetworkTabModel, onEntryHovered: (entry: Entry | undefined) => void, }> = ({ boundaries, networkModel, onEntryHovered }) => { const [sorting, setSorting] = React.useState(undefined); const [selectedEntry, setSelectedEntry] = React.useState(undefined); const { renderedEntries } = React.useMemo(() => { const renderedEntries = networkModel.resources.map(entry => renderEntry(entry, boundaries)); if (sorting) sort(renderedEntries, sorting); return { renderedEntries }; }, [networkModel.resources, sorting, boundaries]); if (!networkModel.resources.length) return ; const grid = setSelectedEntry(item)} onHighlighted={item => onEntryHovered(item?.resource)} columns={selectedEntry ? ['name'] : ['name', 'method', 'status', 'contentType', 'duration', 'size', 'start', 'route']} columnTitle={columnTitle} columnWidth={columnWidth} isError={item => item.status.code >= 400} isInfo={item => !!item.route} render={(item, column) => renderCell(item, column)} sorting={sorting} setSorting={setSorting} />; return <> {!selectedEntry && grid} {selectedEntry && setSelectedEntry(undefined)} /> {grid} } ; }; const columnTitle = (column: ColumnName) => { if (column === 'name') return 'Name'; if (column === 'method') return 'Method'; if (column === 'status') return 'Status'; if (column === 'contentType') return 'Content Type'; if (column === 'duration') return 'Duration'; if (column === 'size') return 'Size'; if (column === 'start') return 'Start'; if (column === 'route') return 'Route'; return ''; }; const columnWidth = (column: ColumnName) => { if (column === 'name') return 200; if (column === 'method') return 60; if (column === 'status') return 60; if (column === 'contentType') return 200; return 100; }; const renderCell = (entry: RenderedEntry, column: ColumnName): RenderedGridCell => { if (column === 'name') { return { body: entry.name.name, title: entry.name.url, }; } if (column === 'method') return { body: entry.method }; if (column === 'status') { return { body: entry.status.code > 0 ? entry.status.code : '', title: entry.status.text }; } if (column === 'contentType') return { body: entry.contentType }; if (column === 'duration') return { body: msToString(entry.duration) }; if (column === 'size') return { body: bytesToString(entry.size) }; if (column === 'start') return { body: msToString(entry.start) }; if (column === 'route') return { body: entry.route }; return { body: '' }; }; const renderEntry = (resource: Entry, boundaries: Boundaries): RenderedEntry => { const routeStatus = formatRouteStatus(resource); let resourceName: string; try { const url = new URL(resource.request.url); resourceName = url.pathname.substring(url.pathname.lastIndexOf('/') + 1); if (!resourceName) resourceName = url.host; } catch { resourceName = resource.request.url; } let contentType = resource.response.content.mimeType; const charset = contentType.match(/^(.*);\s*charset=.*$/); if (charset) contentType = charset[1]; return { name: { name: resourceName, url: resource.request.url }, method: resource.request.method, status: { code: resource.response.status, text: resource.response.statusText }, contentType: contentType, duration: resource.time, size: resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize, start: resource._monotonicTime! - boundaries.minimum, route: routeStatus, resource }; }; function formatRouteStatus(request: Entry): string { if (request._wasAborted) return 'aborted'; if (request._wasContinued) return 'continued'; if (request._wasFulfilled) return 'fulfilled'; if (request._apiRequest) return 'api'; return ''; } function sort(resources: RenderedEntry[], sorting: Sorting) { const c = comparator(sorting?.by); if (c) resources.sort(c); if (sorting.negate) resources.reverse(); } function comparator(sortBy: ColumnName) { if (sortBy === 'start') return (a: RenderedEntry, b: RenderedEntry) => a.start - b.start; if (sortBy === 'duration') return (a: RenderedEntry, b: RenderedEntry) => a.duration - b.duration; if (sortBy === 'status') return (a: RenderedEntry, b: RenderedEntry) => a.status.code - b.status.code; if (sortBy === 'method') { return (a: RenderedEntry, b: RenderedEntry) => { const valueA = a.method; const valueB = b.method; return valueA.localeCompare(valueB); }; } if (sortBy === 'size') { return (a: RenderedEntry, b: RenderedEntry) => { return a.size - b.size; }; } if (sortBy === 'contentType') { return (a: RenderedEntry, b: RenderedEntry) => { return a.contentType.localeCompare(b.contentType); }; } if (sortBy === 'name') { return (a: RenderedEntry, b: RenderedEntry) => { return a.name.name.localeCompare(b.name.name); }; } if (sortBy === 'route') { return (a: RenderedEntry, b: RenderedEntry) => { return a.route.localeCompare(b.route); }; } }