diff --git a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx index 2c17b9fa..4f7715bc 100644 --- a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx +++ b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx @@ -16,35 +16,21 @@ import { Input } from './Input'; import { Button } from './Button'; import { X, Filter, ChevronDown, Check } from 'lucide-react'; import type { HttpMethod, NetworkEventSource } from '../../shared/client'; - -export type RequestTypeFilter = 'http' | 'websocket' | 'sse'; -export type AdvancedFilterState = { - methods: Set; - sources: Set; - status: string; - domain: string; - contentType: string; - failedOnly: boolean; - inFlightOnly: boolean; - overriddenOnly: boolean; - minSize: string; - maxSize: string; - minDuration: string; - maxDuration: string; -}; - -export type FilterState = { - text: string; - types: Set; - advanced: AdvancedFilterState; -}; +import { + createDefaultFilter, + DEFAULT_REQUEST_TYPES, +} from '../state/filter'; +import type { + AdvancedFilterState, + FilterState, + RequestTypeFilter, +} from '../state/filter'; type FilterBarProps = { filter: FilterState; onFilterChange: (filter: FilterState) => void; }; -const REQUEST_TYPES: RequestTypeFilter[] = ['http', 'sse', 'websocket']; const HTTP_METHODS: HttpMethod[] = [ 'GET', 'POST', @@ -55,25 +41,6 @@ const HTTP_METHODS: HttpMethod[] = [ ]; const SOURCES: NetworkEventSource[] = ['builtin', 'nitro']; -export const createDefaultFilter = (): FilterState => ({ - text: '', - types: new Set(), - advanced: { - methods: new Set(), - sources: new Set(), - status: '', - domain: '', - contentType: '', - failedOnly: false, - inFlightOnly: false, - overriddenOnly: false, - minSize: '', - maxSize: '', - minDuration: '', - maxDuration: '', - }, -}); - const getTypeLabel = (type: RequestTypeFilter) => { switch (type) { case 'http': @@ -112,7 +79,8 @@ const getAdvancedFilterCount = (advanced: AdvancedFilterState) => { }; const getActiveFilterCount = (filter: FilterState) => { - const typeFilterCount = filter.types.size > 0 ? 1 : 0; + const typeFilterCount = + filter.types.size < DEFAULT_REQUEST_TYPES.length ? 1 : 0; return typeFilterCount + getAdvancedFilterCount(filter.advanced); }; @@ -250,7 +218,7 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => { }; const activeFilterCount = getActiveFilterCount(filter); - const hasActiveFilters = filter.text !== '' || activeFilterCount > 0; + const hasActiveFilters = filter.text.trim() !== '' || activeFilterCount > 0; return (
@@ -293,7 +261,7 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => { {...getFloatingProps()} > Request Type - {REQUEST_TYPES.map((type) => ( + {DEFAULT_REQUEST_TYPES.map((type) => ( { + if (request.status === 'failed' || request.status === 'error') { + return REQUEST_TIMELINE_COLORS.error; + } + + return REQUEST_TIMELINE_COLORS.primary; +}; + +const getStyle = ( + offsetPercent: number, + widthPercent: number, +): CSSProperties => ({ + left: `${offsetPercent}%`, + width: `${widthPercent}%`, +}); + +const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => { + return ( +
+ {ticks.map((tick) => ( +
+ ))} +
+ ); +}; + +const getTickLabelStyle = (tick: TimelineTick): CSSProperties => { + if (tick.offsetPercent === 0) { + return { + left: 4, + }; + } + + if (tick.offsetPercent === 100) { + return { + right: 4, + }; + } + + return { + left: `${tick.offsetPercent}%`, + }; +}; + +const TimelineTrack = ({ + row, + isSelected, + onSelect, + shouldSuppressSelect, +}: { + row: TimelineRow; + isSelected: boolean; + onSelect: (requestId: RequestId) => void; + shouldSuppressSelect: () => boolean; +}) => { + const primaryBarClassName = row.isActive + ? REQUEST_TIMELINE_COLORS.active + : getPrimaryBarClassName(row.request); + const isSplitHttpBar = + row.request.type === 'http' && + row.ttfbPercent > 0 && + row.receivePercent > 0; + const trackTop = getTimelineTrackTop(row.lane); + const barTop = getTimelineBarTopOffset(); + const positionStyle = { + ...getStyle(row.offsetPercent, row.widthPercent), + top: trackTop, + }; + const durationLabel = row.isActive + ? `${formatTimelineOffset(row.duration)}+` + : formatTimelineOffset(row.duration); + const label = `${row.request.method} ${row.request.name} - ${durationLabel}`; + + return ( + + ); +}; + +type DraftSelection = { + anchorPercent: number; + currentPercent: number; + startedOnTrack: boolean; +}; + +const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100); + +const getPointerPercent = ( + event: PointerEvent, + element: HTMLDivElement, +) => { + const rect = element.getBoundingClientRect(); + + if (rect.width === 0) { + return 0; + } + + return clampPercent(((event.clientX - rect.left) / rect.width) * 100); +}; + +const getSelectionStyle = ( + range: TimelineRangeSelection, + timeline: TimelineModel, +): CSSProperties => { + const startPercent = clampPercent( + ((range.startTime - timeline.rangeStart) / timeline.rangeDuration) * 100, + ); + const endPercent = clampPercent( + ((range.endTime - timeline.rangeStart) / timeline.rangeDuration) * 100, + ); + const left = Math.min(startPercent, endPercent); + const width = Math.abs(endPercent - startPercent); + + return { + left: `${left}%`, + width: `${width}%`, + top: TIMELINE_LAYOUT.rulerHeightPx, + }; +}; + +const getDraftSelectionStyle = (draft: DraftSelection): CSSProperties => { + const left = Math.min(draft.anchorPercent, draft.currentPercent); + const width = Math.abs(draft.currentPercent - draft.anchorPercent); + + return { + left: `${left}%`, + width: `${width}%`, + top: TIMELINE_LAYOUT.rulerHeightPx, + }; +}; + +export type NetworkTimelineProps = { + requests: ProcessedRequest[]; + selection: TimelineRangeSelection | null; + filteredRequestCount: number; + onSelectionChange: (selection: TimelineRangeSelection | null) => void; +}; + +export const NetworkTimeline = ({ + requests, + selection, + filteredRequestCount, + onSelectionChange, +}: NetworkTimelineProps) => { + const actions = useNetworkActivityActions(); + const selectedRequestId = useSelectedRequestId(); + const [now, setNow] = useState(() => Date.now()); + const [draftSelection, setDraftSelection] = useState( + null, + ); + const chartRef = useRef(null); + const suppressTrackClickRef = useRef(false); + + const hasActiveRequests = requests.some(isRequestActive); + + useEffect(() => { + if (!hasActiveRequests) { + return; + } + + const interval = window.setInterval(() => { + setNow(Date.now()); + }, TIMELINE_LAYOUT.liveRefreshMs); + + return () => window.clearInterval(interval); + }, [hasActiveRequests]); + + const timeline = useMemo(() => { + return getTimelineModel(requests, now); + }, [requests, now]); + + const onRequestSelect = (requestId: RequestId) => { + actions.setSelectedRequest(requestId); + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0 || requests.length === 0) { + return; + } + + const chartElement = chartRef.current; + + if (!chartElement) { + return; + } + + const percent = getPointerPercent(event, chartElement); + const target = event.target; + const startedOnTrack = + target instanceof Element && + target.closest('[data-timeline-track="true"]') !== null; + + setDraftSelection({ + anchorPercent: percent, + currentPercent: percent, + startedOnTrack, + }); + chartElement.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!draftSelection) { + return; + } + + const chartElement = chartRef.current; + + if (!chartElement) { + return; + } + + event.preventDefault(); + const percent = getPointerPercent(event, chartElement); + + setDraftSelection((current) => + current ? { ...current, currentPercent: percent } : current, + ); + }; + + const onPointerUp = (event: PointerEvent) => { + if (!draftSelection) { + return; + } + + const chartElement = chartRef.current; + const currentPercent = chartElement + ? getPointerPercent(event, chartElement) + : draftSelection.currentPercent; + const distance = Math.abs(currentPercent - draftSelection.anchorPercent); + + if (distance > 1) { + const startOffset = + (Math.min(draftSelection.anchorPercent, currentPercent) / 100) * + timeline.rangeDuration; + const endOffset = + (Math.max(draftSelection.anchorPercent, currentPercent) / 100) * + timeline.rangeDuration; + + onSelectionChange({ + startTime: timeline.rangeStart + startOffset, + endTime: timeline.rangeStart + endOffset, + }); + + suppressTrackClickRef.current = true; + window.setTimeout(() => { + suppressTrackClickRef.current = false; + }, 0); + } else if (!draftSelection.startedOnTrack) { + onSelectionChange(null); + } + + setDraftSelection(null); + + if (chartElement?.hasPointerCapture(event.pointerId)) { + chartElement.releasePointerCapture(event.pointerId); + } + }; + + return ( +
+ {requests.length === 0 ? ( +
+ No requests match the current filters +
+ ) : ( +
+ + +
+ + {timeline.ticks.map((tick) => ( +
+ {tick.label} +
+ ))} + + {selection && ( +
+ )} + + {draftSelection && ( +
+ )} + + {timeline.rows.map((row) => ( + suppressTrackClickRef.current} + /> + ))} + + {selection && ( +
+ {filteredRequestCount} in range + +
+ )} + + {timeline.hiddenRequestCount > 0 && ( +
+ Showing latest {timeline.rows.length} of{' '} + {timeline.totalRequestCount} +
+ )} +
+ )} +
+ ); +}; diff --git a/packages/network-activity-plugin/src/ui/components/RequestList.tsx b/packages/network-activity-plugin/src/ui/components/RequestList.tsx index fb459a11..4dc48e76 100644 --- a/packages/network-activity-plugin/src/ui/components/RequestList.tsx +++ b/packages/network-activity-plugin/src/ui/components/RequestList.tsx @@ -10,7 +10,6 @@ import { } from '@tanstack/react-table'; import type { ProcessedRequest } from '../state/model'; import type { - HttpMethod, NetworkEventSource, RequestId, RequestOverride, @@ -18,12 +17,10 @@ import type { import { useNetworkActivityActions, useOverrides, - useProcessedRequests, useSelectedRequestId, useClientUISettings, } from '../state/hooks'; import { getStatusColor } from '../utils/getStatusColor'; -import { FilterState } from './FilterBar'; import { isNumber } from '../../utils/typeChecks'; type NetworkRequest = { @@ -31,7 +28,6 @@ type NetworkRequest = { name: string; status: string | number; statusCode?: number; - statusState: ProcessedRequest['status']; method: ProcessedRequest['method']; domain: string; path: string; @@ -114,7 +110,6 @@ const sortSize: SortingFn = (rowA, rowB, columnId) => { const a = rowA.getValue(columnId) as string; const b = rowB.getValue(columnId) as string; - // Extract numeric values from formatted strings like "1.2 kB", "500 B", etc. const getNumericValue = (str: string) => { const match = str.match(/^([\d.]+)\s*([KMGT]?B)$/); if (!match) return 0; @@ -136,7 +131,6 @@ const sortTime: SortingFn = (rowA, rowB, columnId) => { const a = rowA.getValue(columnId) as string; const b = rowB.getValue(columnId) as string; - // Extract numeric values from formatted strings like "150 ms", "1.2 s", etc. const getNumericValue = (str: string) => { const match = str.match(/^([\d.]+)\s*(ms|s)$/); if (!match) return 0; @@ -148,175 +142,6 @@ const sortTime: SortingFn = (rowA, rowB, columnId) => { return getNumericValue(a) - getNumericValue(b); }; -const parseThreshold = (value: string): number | null => { - const normalizedValue = value.trim(); - if (!normalizedValue) { - return null; - } - - const parsedValue = Number(normalizedValue); - return Number.isFinite(parsedValue) ? parsedValue : null; -}; - -const matchesStatusFilter = ( - statusCode: number | undefined, - statusFilter: string, -) => { - const normalizedFilter = statusFilter.trim().toLowerCase(); - if (!normalizedFilter) { - return true; - } - - if (statusCode === undefined) { - return false; - } - - const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/); - if (statusRangeMatch) { - const min = Number(statusRangeMatch[1]); - const max = Number(statusRangeMatch[2]); - return statusCode >= min && statusCode <= max; - } - - const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/); - if (statusClassMatch) { - return Math.floor(statusCode / 100) === Number(statusClassMatch[1]); - } - - const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/); - if (comparisonMatch) { - const value = Number(comparisonMatch[2]); - switch (comparisonMatch[1]) { - case '>=': - return statusCode >= value; - case '<=': - return statusCode <= value; - case '>': - return statusCode > value; - case '<': - return statusCode < value; - } - } - - return statusCode === Number(normalizedFilter); -}; - -const isInFlightStatus = (status: string) => { - return ['pending', 'loading', 'connecting', 'open'].includes(status); -}; - -const isFailedStatus = (status: string) => { - return ['failed', 'error'].includes(status); -}; - -const isHttpMethod = (method: NetworkRequest['method']): method is HttpMethod => - method !== 'WS' && method !== 'SSE'; - -const filterNetworkRequests = ( - requests: NetworkRequest[], - filter: FilterState, -) => { - const searchText = filter.text.trim().toLowerCase(); - const domainFilter = filter.advanced.domain.trim().toLowerCase(); - const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase(); - const minSize = parseThreshold(filter.advanced.minSize); - const maxSize = parseThreshold(filter.advanced.maxSize); - const minDuration = parseThreshold(filter.advanced.minDuration); - const maxDuration = parseThreshold(filter.advanced.maxDuration); - - return requests.filter((request) => { - if (filter.types.size > 0 && !filter.types.has(request.type)) { - return false; - } - - if ( - filter.advanced.methods.size > 0 && - (!isHttpMethod(request.method) || - !filter.advanced.methods.has(request.method)) - ) { - return false; - } - - if ( - filter.advanced.sources.size > 0 && - (!request.source || !filter.advanced.sources.has(request.source)) - ) { - return false; - } - - if (!matchesStatusFilter(request.statusCode, filter.advanced.status)) { - return false; - } - - if (domainFilter && !request.domain.toLowerCase().includes(domainFilter)) { - return false; - } - - if ( - contentTypeFilter && - !request.contentType?.toLowerCase().includes(contentTypeFilter) - ) { - return false; - } - - if (filter.advanced.failedOnly && !isFailedStatus(request.statusState)) { - return false; - } - - if ( - filter.advanced.inFlightOnly && - !isInFlightStatus(request.statusState) - ) { - return false; - } - - if (filter.advanced.overriddenOnly && !request.hasOverride) { - return false; - } - - if ( - minSize !== null && - (request.sizeBytes === null || request.sizeBytes < minSize) - ) { - return false; - } - - if ( - maxSize !== null && - (request.sizeBytes === null || request.sizeBytes > maxSize) - ) { - return false; - } - - if (minDuration !== null && request.durationMs < minDuration) { - return false; - } - - if (maxDuration !== null && request.durationMs > maxDuration) { - return false; - } - - if (searchText) { - const searchableFields = [ - request.name, - request.method, - request.status, - request.domain, - request.path, - request.source, - request.type, - request.contentType, - ] - .join(' ') - .toLowerCase(); - - return searchableFields.includes(searchText); - } - - return true; - }); -}; - const processNetworkRequests = ( processedRequests: ProcessedRequest[], overrides: Map, @@ -340,7 +165,6 @@ const processNetworkRequests = ( name: generateName(request.name, showEntirePathAsName), status: statusDisplay, statusCode: request.httpStatus || undefined, - statusState: request.status, method: request.method, domain, path, @@ -352,7 +176,7 @@ const processNetworkRequests = ( type: request.type, source: request.source, startTime: formatStartTime(request.timestamp), - hasOverride: hasOverride, + hasOverride, }; }); }; @@ -428,25 +252,23 @@ const columns = [ ]; export type RequestListProps = { - filter: FilterState; + requests: ProcessedRequest[]; }; -export const RequestList = ({ filter }: RequestListProps) => { +export const RequestList = ({ requests: filteredRequests }: RequestListProps) => { const actions = useNetworkActivityActions(); - const processedRequests = useProcessedRequests(); const selectedRequestId = useSelectedRequestId(); const [sorting, setSorting] = useState([]); const overrides = useOverrides(); const clientUISettings = useClientUISettings(); const requests = useMemo(() => { - const allRequests = processNetworkRequests( - processedRequests, + return processNetworkRequests( + filteredRequests, overrides, clientUISettings?.showUrlAsName, ); - return filterNetworkRequests(allRequests, filter); - }, [processedRequests, overrides, clientUISettings?.showUrlAsName, filter]); + }, [filteredRequests, overrides, clientUISettings?.showUrlAsName]); const table = useReactTable({ data: requests, @@ -527,7 +349,6 @@ export const RequestList = ({ filter }: RequestListProps) => { ); }; -// Export helper functions for use in other components export { formatSize, formatDuration, diff --git a/packages/network-activity-plugin/src/ui/state/__tests__/store.test.ts b/packages/network-activity-plugin/src/ui/state/__tests__/store.test.ts new file mode 100644 index 00000000..4618bc70 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/state/__tests__/store.test.ts @@ -0,0 +1,77 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import type { createNetworkActivityStore as createStoreType } from '../store'; + +let createNetworkActivityStore: typeof createStoreType; + +beforeAll(async () => { + const storage = new Map(); + + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }); + + ({ createNetworkActivityStore } = await import('../store')); +}); + +describe('network activity store', () => { + it('records elapsed duration for failed HTTP requests', () => { + const store = createNetworkActivityStore(); + + store.getState().handleEvent('request-sent', { + requestId: 'request-1', + timestamp: 100, + request: { + url: 'https://example.com/api', + method: 'GET', + headers: {}, + }, + initiator: { + type: 'script', + }, + type: 'Fetch', + }); + store.getState().handleEvent('request-failed', { + requestId: 'request-1', + timestamp: 250, + type: 'Fetch', + error: 'Network request failed', + canceled: false, + }); + + expect(store.getState().networkEntries.get('request-1')).toMatchObject({ + status: 'failed', + duration: 150, + }); + }); + + it('records elapsed duration for websocket errors', () => { + const store = createNetworkActivityStore(); + + store.getState().handleEvent('websocket-connect', { + type: 'websocket-connect', + url: 'wss://example.com/socket', + socketId: 'socket-1', + timestamp: 100, + protocols: null, + options: [], + }); + store.getState().handleEvent('websocket-error', { + type: 'websocket-error', + url: 'wss://example.com/socket', + socketId: 'socket-1', + timestamp: 175, + error: 'Socket failed', + }); + + expect(store.getState().networkEntries.get('ws-socket-1')).toMatchObject({ + status: 'error', + duration: 75, + }); + }); +}); diff --git a/packages/network-activity-plugin/src/ui/state/derived.ts b/packages/network-activity-plugin/src/ui/state/derived.ts index 0a2f718e..1ccc907e 100644 --- a/packages/network-activity-plugin/src/ui/state/derived.ts +++ b/packages/network-activity-plugin/src/ui/state/derived.ts @@ -27,6 +27,7 @@ export const getProcessedRequests = memoize((state: NetworkActivityState) => { method: httpEntry.request.method, httpStatus: httpEntry.response?.status, contentType: httpEntry.response?.contentType, + ttfb: httpEntry.ttfb, progress: httpEntry.progress, }); } else if (entry.type === 'websocket') { @@ -95,6 +96,7 @@ export const getRequestSummary = ( method: httpEntry.request.method, httpStatus: httpEntry.response?.status || 0, contentType: httpEntry.response?.contentType, + ttfb: httpEntry.ttfb, progress: httpEntry.progress, }; } else if (entry.type === 'websocket') { diff --git a/packages/network-activity-plugin/src/ui/state/filter.ts b/packages/network-activity-plugin/src/ui/state/filter.ts new file mode 100644 index 00000000..2eb0d270 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/state/filter.ts @@ -0,0 +1,49 @@ +import type { HttpMethod, NetworkEventSource } from '../../shared/client'; + +export type RequestTypeFilter = 'http' | 'websocket' | 'sse'; + +export type AdvancedFilterState = { + methods: Set; + sources: Set; + status: string; + domain: string; + contentType: string; + failedOnly: boolean; + inFlightOnly: boolean; + overriddenOnly: boolean; + minSize: string; + maxSize: string; + minDuration: string; + maxDuration: string; +}; + +export type FilterState = { + text: string; + types: Set; + advanced: AdvancedFilterState; +}; + +export const DEFAULT_REQUEST_TYPES: RequestTypeFilter[] = [ + 'http', + 'websocket', + 'sse', +]; + +export const createDefaultFilter = (): FilterState => ({ + text: '', + types: new Set(DEFAULT_REQUEST_TYPES), + advanced: { + methods: new Set(), + sources: new Set(), + status: '', + domain: '', + contentType: '', + failedOnly: false, + inFlightOnly: false, + overriddenOnly: false, + minSize: '', + maxSize: '', + minDuration: '', + maxDuration: '', + }, +}); diff --git a/packages/network-activity-plugin/src/ui/state/model.ts b/packages/network-activity-plugin/src/ui/state/model.ts index 3e0a2fd9..48299bb8 100644 --- a/packages/network-activity-plugin/src/ui/state/model.ts +++ b/packages/network-activity-plugin/src/ui/state/model.ts @@ -152,6 +152,7 @@ export type ProcessedRequest = { method: HttpMethod | 'WS' | 'SSE'; httpStatus?: number; contentType?: string; + ttfb?: number; progress?: { loaded: number; total: number; diff --git a/packages/network-activity-plugin/src/ui/state/store.ts b/packages/network-activity-plugin/src/ui/state/store.ts index ad3fcef1..f2ca8cff 100644 --- a/packages/network-activity-plugin/src/ui/state/store.ts +++ b/packages/network-activity-plugin/src/ui/state/store.ts @@ -26,6 +26,10 @@ const MAX_SSE_MESSAGES_PER_CONNECTION = 32; const STORE_VERSION = 1; +const getElapsedDuration = (endTimestamp: number, startTimestamp: number) => { + return Math.max(endTimestamp - startTimestamp, 0); +}; + export interface NetworkActivityState { // State isRecording: boolean; @@ -302,6 +306,10 @@ export const createNetworkActivityStore = () => const updatedEntry: HttpNetworkEntry = { ...httpEntry, status: 'failed', + duration: getElapsedDuration( + eventData.timestamp, + httpEntry.timestamp, + ), error: eventData.error, }; @@ -414,7 +422,10 @@ export const createNetworkActivityStore = () => status: 'closed', closeCode: eventData.code, closeReason: eventData.reason, - duration: eventData.timestamp - wsEntry.timestamp, + duration: getElapsedDuration( + eventData.timestamp, + wsEntry.timestamp, + ), }; const newEntries = new Map(state.networkEntries); @@ -495,6 +506,10 @@ export const createNetworkActivityStore = () => const updatedEntry: WebSocketNetworkEntry = { ...wsEntry, status: 'error', + duration: getElapsedDuration( + eventData.timestamp, + wsEntry.timestamp, + ), error: eventData.error, }; @@ -591,6 +606,10 @@ export const createNetworkActivityStore = () => const updatedEntry: SSENetworkEntry = { ...sseEntry, status: 'error', + duration: getElapsedDuration( + eventData.timestamp, + sseEntry.timestamp, + ), error: eventData.error.message, }; @@ -611,7 +630,10 @@ export const createNetworkActivityStore = () => const updatedEntry: SSENetworkEntry = { ...sseEntry, status: 'closed', - duration: eventData.timestamp - sseEntry.timestamp, + duration: getElapsedDuration( + eventData.timestamp, + sseEntry.timestamp, + ), }; const newEntries = new Map(state.networkEntries); diff --git a/packages/network-activity-plugin/src/ui/utils/__tests__/requestFilters.test.ts b/packages/network-activity-plugin/src/ui/utils/__tests__/requestFilters.test.ts new file mode 100644 index 00000000..2a78bf6f --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/__tests__/requestFilters.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultFilter } from '../../state/filter'; +import type { FilterState } from '../../state/filter'; +import type { ProcessedRequest } from '../../state/model'; +import { matchesRequestFilter } from '../requestFilters'; + +const allTypesFilter = (text: string): FilterState => ({ + ...createDefaultFilter(), + text, +}); + +const request: ProcessedRequest = { + id: 'request-1', + type: 'http', + name: 'https://example.com/users', + status: 'finished', + timestamp: 0, + duration: 100, + size: null, + method: 'GET', + httpStatus: 404, +}; + +describe('matchesRequestFilter', () => { + it('matches HTTP status codes', () => { + expect(matchesRequestFilter(request, allTypesFilter('404'))).toBe(true); + }); + + it('ignores whitespace-only text filters', () => { + expect(matchesRequestFilter(request, allTypesFilter(' '))).toBe(true); + }); +}); diff --git a/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts b/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts new file mode 100644 index 00000000..ba14d1fd --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import type { ProcessedRequest } from '../../state/model'; +import { + formatTimelineOffset, + getTimelineModel, + getTimelineRequestEndTime, + getTimelineTicks, + isRequestActive, + requestOverlapsTimelineRange, + TIMELINE_LAYOUT, +} from '../timelineModel'; + +const createRequest = ( + overrides: Partial = {}, +): ProcessedRequest => ({ + id: 'request-1', + type: 'http', + name: 'https://example.com/api', + status: 'finished', + timestamp: 0, + duration: 100, + size: null, + method: 'GET', + ...overrides, +}); + +describe('timelineModel', () => { + it('formats minute offsets without rolling seconds up to 60', () => { + expect(formatTimelineOffset(119_900)).toBe('2m 00s'); + }); + + it('treats websocket closing state as active', () => { + expect( + isRequestActive( + createRequest({ + type: 'websocket', + status: 'closing', + method: 'WS', + }), + ), + ).toBe(true); + }); + + it('uses the earliest ending lane when all lanes are occupied', () => { + const requests = Array.from( + { length: TIMELINE_LAYOUT.laneCount }, + (_, index) => + createRequest({ + id: `request-${index}`, + timestamp: 0, + duration: index === 1 ? 100 : 1000, + }), + ); + + const overflowingRequest = createRequest({ + id: 'overflowing-request', + timestamp: 50, + duration: 200, + }); + + const model = getTimelineModel([...requests, overflowingRequest], 0); + const overflowingRow = model.rows.find( + (row) => row.request.id === overflowingRequest.id, + ); + + expect(overflowingRow?.lane).toBe(1); + expect(overflowingRow?.isOverflowingLane).toBe(true); + }); + + it('keeps long-session tick counts near the target count', () => { + const oneHour = 60 * 60 * 1000; + const model = getTimelineModel( + [ + createRequest({ + duration: oneHour, + }), + ], + 0, + ); + + expect(model.ticks.length).toBeLessThanOrEqual( + TIMELINE_LAYOUT.tickTargetCount + 2, + ); + }); + + it('does not add a duplicate final tick label', () => { + const ticks = getTimelineTicks(1548, { + ...TIMELINE_LAYOUT, + tickTargetCount: 7, + }); + + expect(ticks.map((tick) => tick.label)).toEqual([ + '0 ms', + '250 ms', + '500 ms', + '750 ms', + '1.0 s', + '1.3 s', + '1.5 s', + ]); + }); + + it('caps rendered rows for large recordings', () => { + const maxRenderedRequests = 3; + const model = getTimelineModel( + Array.from({ length: 5 }, (_, index) => + createRequest({ + id: `request-${index}`, + timestamp: index, + }), + ), + 0, + { + ...TIMELINE_LAYOUT, + maxRenderedRequests, + }, + ); + + expect(model.rows.map((row) => row.request.id)).toEqual([ + 'request-2', + 'request-3', + 'request-4', + ]); + expect(model.totalRequestCount).toBe(5); + expect(model.hiddenRequestCount).toBe(2); + }); + + it('caps websocket and SSE duration in the timeline model', () => { + const now = 60_000; + const websocketRequest = createRequest({ + type: 'websocket', + status: 'open', + method: 'WS', + timestamp: 0, + duration: undefined, + }); + + expect(getTimelineRequestEndTime(websocketRequest, now)).toBe( + TIMELINE_LAYOUT.streamingRequestMaxDurationMs, + ); + + const model = getTimelineModel([websocketRequest], now); + + expect(model.rows[0].duration).toBe( + TIMELINE_LAYOUT.streamingRequestMaxDurationMs, + ); + }); + + it('matches requests that overlap a selected timeline range', () => { + const request = createRequest({ + timestamp: 1000, + duration: 400, + }); + + expect( + requestOverlapsTimelineRange( + request, + { startTime: 1200, endTime: 1400 }, + 0, + ), + ).toBe(true); + expect( + requestOverlapsTimelineRange( + request, + { startTime: 1500, endTime: 1600 }, + 0, + ), + ).toBe(false); + }); +}); diff --git a/packages/network-activity-plugin/src/ui/utils/requestFilters.ts b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts new file mode 100644 index 00000000..179c9f19 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts @@ -0,0 +1,183 @@ +import type { FilterState } from '../state/filter'; +import type { ProcessedRequest } from '../state/model'; +import type { HttpMethod } from '../../shared/client'; + +export type RequestFilterOptions = { + hasOverride?: boolean; +}; + +const parseThreshold = (value: string): number | null => { + const normalizedValue = value.trim(); + if (!normalizedValue) { + return null; + } + + const parsedValue = Number(normalizedValue); + return Number.isFinite(parsedValue) ? parsedValue : null; +}; + +const matchesStatusFilter = ( + statusCode: number | undefined, + statusFilter: string, +) => { + const normalizedFilter = statusFilter.trim().toLowerCase(); + if (!normalizedFilter) { + return true; + } + + if (statusCode === undefined) { + return false; + } + + const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/); + if (statusRangeMatch) { + const min = Number(statusRangeMatch[1]); + const max = Number(statusRangeMatch[2]); + return statusCode >= min && statusCode <= max; + } + + const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/); + if (statusClassMatch) { + return Math.floor(statusCode / 100) === Number(statusClassMatch[1]); + } + + const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/); + if (comparisonMatch) { + const value = Number(comparisonMatch[2]); + switch (comparisonMatch[1]) { + case '>=': + return statusCode >= value; + case '<=': + return statusCode <= value; + case '>': + return statusCode > value; + case '<': + return statusCode < value; + } + } + + return statusCode === Number(normalizedFilter); +}; + +const isInFlightStatus = (status: ProcessedRequest['status']) => { + return ['pending', 'loading', 'connecting', 'open'].includes(status); +}; + +const isFailedStatus = (status: ProcessedRequest['status']) => { + return ['failed', 'error'].includes(status); +}; + +const isHttpMethod = ( + method: ProcessedRequest['method'], +): method is HttpMethod => method !== 'WS' && method !== 'SSE'; + +const extractDomainAndPath = (url: string) => { + try { + const { hostname, pathname, search, hash, port } = new URL(url); + + return { + domain: `${hostname}${port ? `:${port}` : ''}`, + path: `${pathname}${search}${hash}`, + }; + } catch { + return { domain: 'unknown', path: url }; + } +}; + +export const matchesRequestFilter = ( + request: ProcessedRequest, + filter: FilterState, + options: RequestFilterOptions = {}, +) => { + if (filter.types.size > 0 && !filter.types.has(request.type)) { + return false; + } + + if ( + filter.advanced.methods.size > 0 && + (!isHttpMethod(request.method) || + !filter.advanced.methods.has(request.method)) + ) { + return false; + } + + if ( + filter.advanced.sources.size > 0 && + (!request.source || !filter.advanced.sources.has(request.source)) + ) { + return false; + } + + if (!matchesStatusFilter(request.httpStatus, filter.advanced.status)) { + return false; + } + + const { domain, path } = extractDomainAndPath(request.name); + const domainFilter = filter.advanced.domain.trim().toLowerCase(); + if (domainFilter && !domain.toLowerCase().includes(domainFilter)) { + return false; + } + + const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase(); + if ( + contentTypeFilter && + !request.contentType?.toLowerCase().includes(contentTypeFilter) + ) { + return false; + } + + if (filter.advanced.failedOnly && !isFailedStatus(request.status)) { + return false; + } + + if (filter.advanced.inFlightOnly && !isInFlightStatus(request.status)) { + return false; + } + + if (filter.advanced.overriddenOnly && !options.hasOverride) { + return false; + } + + const minSize = parseThreshold(filter.advanced.minSize); + if (minSize !== null && (request.size === null || request.size < minSize)) { + return false; + } + + const maxSize = parseThreshold(filter.advanced.maxSize); + if (maxSize !== null && (request.size === null || request.size > maxSize)) { + return false; + } + + const duration = request.duration || 0; + const minDuration = parseThreshold(filter.advanced.minDuration); + if (minDuration !== null && duration < minDuration) { + return false; + } + + const maxDuration = parseThreshold(filter.advanced.maxDuration); + if (maxDuration !== null && duration > maxDuration) { + return false; + } + + const searchText = filter.text.trim().toLowerCase(); + if (!searchText) { + return true; + } + + const searchableFields = [ + request.name, + request.method, + request.status, + request.httpStatus, + request.source, + request.type, + request.contentType, + domain, + path, + ] + .filter((value) => value !== undefined && value !== null) + .join(' ') + .toLowerCase(); + + return searchableFields.includes(searchText); +}; diff --git a/packages/network-activity-plugin/src/ui/utils/timelineModel.ts b/packages/network-activity-plugin/src/ui/utils/timelineModel.ts new file mode 100644 index 00000000..a0bc06ad --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/timelineModel.ts @@ -0,0 +1,352 @@ +import type { ProcessedRequest } from '../state/model'; + +export const TIMELINE_LAYOUT = { + minVisibleBarPercent: 0.65, + minRangeMs: 1000, + liveRefreshMs: 1000, + maxRenderedRequests: 1000, + laneCount: 8, + laneHeightPx: 2, + laneGapPx: 6, + laneHitTargetHeightPx: 8, + rulerHeightPx: 22, + laneTopPx: 32, + laneBottomPaddingPx: 18, + tickTargetCount: 7, + minTickLabelGapPercent: 6, + rangePaddingRatio: 0.025, + minRangePaddingMs: 25, + streamingRequestMaxDurationMs: 5000, +} as const; + +const NICE_TICK_FACTORS = [1, 2, 2.5, 5, 10] as const; + +type TimelineLayout = { + [Key in keyof typeof TIMELINE_LAYOUT]: number; +}; + +export type TimelineTick = { + label: string; + offsetPercent: number; +}; + +export type TimelineRangeSelection = { + startTime: number; + endTime: number; +}; + +export type TimelineRow = { + request: ProcessedRequest; + offsetPercent: number; + widthPercent: number; + duration: number; + ttfbPercent: number; + receivePercent: number; + isActive: boolean; + lane: number; + isOverflowingLane: boolean; +}; + +export type TimelineModel = { + rows: TimelineRow[]; + ticks: TimelineTick[]; + rangeStart: number; + rangeDuration: number; + chartHeight: number; + totalRequestCount: number; + hiddenRequestCount: number; +}; + +const ACTIVE_HTTP_STATUSES = new Set([ + 'pending', + 'loading', +]); +const ACTIVE_WEBSOCKET_STATUSES = new Set([ + 'connecting', + 'open', + 'closing', +]); +const ACTIVE_SSE_STATUSES = new Set([ + 'connecting', + 'open', +]); + +const clamp = (value: number, minimum: number, maximum: number) => { + return Math.min(Math.max(value, minimum), maximum); +}; + +export const getTimelineChartHeight = ( + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + return ( + layout.laneTopPx + + layout.laneCount * layout.laneHeightPx + + (layout.laneCount - 1) * layout.laneGapPx + + layout.laneBottomPaddingPx + ); +}; + +export const getTimelineLaneTop = ( + lane: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + return lane * (layout.laneHeightPx + layout.laneGapPx) + layout.laneTopPx; +}; + +export const getTimelineTrackTop = ( + lane: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + const visualBarTop = getTimelineLaneTop(lane, layout); + return ( + visualBarTop - (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2 + ); +}; + +export const getTimelineBarTopOffset = ( + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + return (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2; +}; + +export const isRequestActive = (request: ProcessedRequest) => { + switch (request.type) { + case 'http': + return ACTIVE_HTTP_STATUSES.has(request.status); + case 'websocket': + return ACTIVE_WEBSOCKET_STATUSES.has(request.status); + case 'sse': + return ACTIVE_SSE_STATUSES.has(request.status); + } +}; + +export const formatTimelineOffset = (milliseconds: number) => { + if (milliseconds < 1000) { + return `${Math.round(milliseconds)} ms`; + } + + if (milliseconds < 60000) { + return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)} s`; + } + + const totalSeconds = Math.round(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; +}; + +export const getRequestEndTime = (request: ProcessedRequest, now: number) => { + if (typeof request.duration === 'number') { + return request.timestamp + Math.max(request.duration, 0); + } + + if (isRequestActive(request)) { + return Math.max(now, request.timestamp); + } + + return request.timestamp; +}; + +export const getTimelineRequestEndTime = ( + request: ProcessedRequest, + now: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + const endTime = getRequestEndTime(request, now); + + if (request.type !== 'websocket' && request.type !== 'sse') { + return endTime; + } + + return Math.min( + endTime, + request.timestamp + layout.streamingRequestMaxDurationMs, + ); +}; + +export const requestOverlapsTimelineRange = ( + request: ProcessedRequest, + range: TimelineRangeSelection, + now: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +) => { + const rangeStart = Math.min(range.startTime, range.endTime); + const rangeEnd = Math.max(range.startTime, range.endTime); + const requestStart = request.timestamp; + const requestEnd = getTimelineRequestEndTime(request, now, layout); + + return requestStart <= rangeEnd && requestEnd >= rangeStart; +}; + +const getNiceTickStep = (rangeDuration: number, targetTickCount: number) => { + const targetStep = rangeDuration / targetTickCount; + const exponent = Math.floor(Math.log10(targetStep)); + const magnitude = 10 ** exponent; + const normalizedStep = targetStep / magnitude; + const factor = + NICE_TICK_FACTORS.find((candidate) => candidate >= normalizedStep) ?? + NICE_TICK_FACTORS[NICE_TICK_FACTORS.length - 1]; + + return factor * magnitude; +}; + +export const getTimelineTicks = ( + rangeDuration: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +): TimelineTick[] => { + const step = getNiceTickStep(rangeDuration, layout.tickTargetCount); + const ticks: TimelineTick[] = []; + + for (let value = 0; value <= rangeDuration; value += step) { + ticks.push({ + label: formatTimelineOffset(value), + offsetPercent: (value / rangeDuration) * 100, + }); + } + + if ( + ticks.length === 0 || + ticks[ticks.length - 1].offsetPercent < 100 - Number.EPSILON + ) { + const finalTick = { + label: formatTimelineOffset(rangeDuration), + offsetPercent: 100, + }; + const previousTick = ticks[ticks.length - 1]; + + if ( + !previousTick || + (finalTick.label !== previousTick.label && + finalTick.offsetPercent - previousTick.offsetPercent >= + layout.minTickLabelGapPercent) + ) { + ticks.push(finalTick); + } + } + + return ticks; +}; + +const getTimelineBounds = ( + requests: ProcessedRequest[], + now: number, + layout: TimelineLayout, +) => { + return requests.reduce( + (result, request) => { + const endTime = getTimelineRequestEndTime(request, now, layout); + + return { + start: Math.min(result.start, request.timestamp), + end: Math.max(result.end, endTime), + }; + }, + { + start: Number.POSITIVE_INFINITY, + end: Number.NEGATIVE_INFINITY, + }, + ); +}; + +const getEarliestLaneIndex = (laneEndTimes: number[]) => { + return laneEndTimes.reduce((earliestIndex, laneEndTime, index) => { + return laneEndTime < laneEndTimes[earliestIndex] ? index : earliestIndex; + }, 0); +}; + +const getRenderableRequests = ( + requests: ProcessedRequest[], + layout: TimelineLayout, +) => { + if (requests.length <= layout.maxRenderedRequests) { + return requests; + } + + return [...requests] + .sort((a, b) => b.timestamp - a.timestamp) + .slice(0, layout.maxRenderedRequests); +}; + +export const getTimelineModel = ( + requests: ProcessedRequest[], + now: number, + layout: TimelineLayout = TIMELINE_LAYOUT, +): TimelineModel => { + const renderableRequests = getRenderableRequests(requests, layout); + const hiddenRequestCount = requests.length - renderableRequests.length; + + if (renderableRequests.length === 0) { + return { + rows: [], + ticks: getTimelineTicks(layout.minRangeMs, layout), + rangeStart: 0, + rangeDuration: layout.minRangeMs, + chartHeight: getTimelineChartHeight(layout), + totalRequestCount: requests.length, + hiddenRequestCount, + }; + } + + const bounds = getTimelineBounds(renderableRequests, now, layout); + const rawRangeDuration = Math.max( + bounds.end - bounds.start, + layout.minRangeMs, + ); + const rangePadding = Math.max( + rawRangeDuration * layout.rangePaddingRatio, + layout.minRangePaddingMs, + ); + const rangeStart = bounds.start - rangePadding; + const rangeDuration = rawRangeDuration + rangePadding * 2; + const laneEndTimes = Array.from({ length: layout.laneCount }, () => 0); + + const rows = [...renderableRequests] + .sort((a, b) => a.timestamp - b.timestamp) + .map((request): TimelineRow => { + const startTime = request.timestamp; + const endTime = getTimelineRequestEndTime(request, now, layout); + const duration = Math.max(endTime - startTime, 0); + const offsetPercent = clamp( + ((startTime - rangeStart) / rangeDuration) * 100, + 0, + 100 - layout.minVisibleBarPercent, + ); + const widthPercent = Math.min( + Math.max((duration / rangeDuration) * 100, layout.minVisibleBarPercent), + 100 - offsetPercent, + ); + const ttfb = clamp(request.ttfb ?? 0, 0, duration); + const ttfbPercent = duration === 0 ? 0 : (ttfb / duration) * 100; + const receivePercent = Math.max(100 - ttfbPercent, 0); + const availableLane = laneEndTimes.findIndex( + (laneEndTime) => laneEndTime <= startTime, + ); + const isOverflowingLane = availableLane === -1; + const lane = isOverflowingLane + ? getEarliestLaneIndex(laneEndTimes) + : availableLane; + laneEndTimes[lane] = Math.max(laneEndTimes[lane], endTime); + + return { + request, + offsetPercent, + widthPercent, + duration, + ttfbPercent, + receivePercent, + isActive: isRequestActive(request), + lane, + isOverflowingLane, + }; + }); + + return { + rows, + ticks: getTimelineTicks(rangeDuration, layout), + rangeStart, + rangeDuration, + chartHeight: getTimelineChartHeight(layout), + totalRequestCount: requests.length, + hiddenRequestCount, + }; +}; diff --git a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx index 95e37ef6..6a992323 100644 --- a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx +++ b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx @@ -1,19 +1,24 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Toolbar } from '../components/Toolbar'; import { RequestList } from '../components/RequestList'; import { SidePanel } from '../components/SidePanel'; -import { - createDefaultFilter, - FilterBar, - FilterState, -} from '../components/FilterBar'; +import { FilterBar } from '../components/FilterBar'; +import { NetworkTimeline } from '../components/NetworkTimeline'; import { NetworkActivityDevToolsClient } from '../../shared/client'; import { useNetworkActivityClientManagement, useHasSelectedRequest, useNetworkActivityActions, useOverrides, + useProcessedRequests, } from '../state/hooks'; +import { createDefaultFilter } from '../state/filter'; +import type { FilterState } from '../state/filter'; +import { matchesRequestFilter } from '../utils/requestFilters'; +import { + requestOverlapsTimelineRange, + type TimelineRangeSelection, +} from '../utils/timelineModel'; export type InspectorViewProps = { client: NetworkActivityDevToolsClient; @@ -24,9 +29,31 @@ export const InspectorView = ({ client }: InspectorViewProps) => { const clientManagement = useNetworkActivityClientManagement(); const hasSelectedRequest = useHasSelectedRequest(); const overrides = useOverrides(); + const processedRequests = useProcessedRequests(); const [filter, setFilter] = useState(() => createDefaultFilter(), ); + const [timelineSelection, setTimelineSelection] = + useState(null); + + const filteredRequests = useMemo(() => { + return processedRequests.filter((request) => + matchesRequestFilter(request, filter, { + hasOverride: overrides.has(request.name), + }), + ); + }, [processedRequests, filter, overrides]); + + const visibleRequests = useMemo(() => { + if (!timelineSelection) { + return filteredRequests; + } + + const now = Date.now(); + return filteredRequests.filter((request) => + requestOverlapsTimelineRange(request, timelineSelection, now), + ); + }, [filteredRequests, timelineSelection]); useEffect(() => { if (!client) { @@ -52,13 +79,18 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
- {/* Request List */}
- + +
{hasSelectedRequest && }