From b94977d4c7839a7fda1b87a6cbe406e367ed116d Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Thu, 14 May 2026 10:43:57 +0200 Subject: [PATCH 1/3] feat(network-activity-plugin): add overview timeline --- .../src/ui/components/NetworkTimeline.tsx | 350 ++++++++++++++++++ .../src/ui/state/derived.ts | 2 + .../src/ui/state/model.ts | 1 + .../src/ui/utils/requestFilters.ts | 180 +++++++++ .../src/ui/views/InspectorView.tsx | 2 + 5 files changed, 535 insertions(+) create mode 100644 packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx create mode 100644 packages/network-activity-plugin/src/ui/utils/requestFilters.ts diff --git a/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx b/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx new file mode 100644 index 00000000..231acc69 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx @@ -0,0 +1,350 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { CSSProperties } from 'react'; +import type { RequestId } from '../../shared/client'; +import type { ProcessedRequest } from '../state/model'; +import { + useNetworkActivityActions, + useOverrides, + useProcessedRequests, + useSelectedRequestId, +} from '../state/hooks'; +import { matchesRequestFilter } from '../utils/requestFilters'; +import type { FilterState } from './FilterBar'; + +const MIN_VISIBLE_BAR_PERCENT = 0.65; +const MIN_RANGE_MS = 1000; +const LIVE_REFRESH_MS = 1000; +const CHART_LANE_COUNT = 8; +const CHART_LANE_HEIGHT = 3; +const CHART_LANE_GAP = 4; +const CHART_RULER_HEIGHT = 28; +const CHART_LANE_TOP = 44; +const TICK_TARGET_COUNT = 7; +const NICE_TICK_STEPS = [ + 50, 100, 250, 500, 1000, 2000, 5000, 10000, 15000, 30000, 60000, 120000, + 300000, +]; + +type TimelineRequest = { + request: ProcessedRequest; + startTime: number; + endTime: number; + offsetPercent: number; + widthPercent: number; + duration: number; + ttfbPercent: number; + receivePercent: number; + isActive: boolean; + lane: number; +}; + +type TimelineTick = { + label: string; + offsetPercent: number; +}; + +const activeStatuses = new Set(['pending', 'loading', 'connecting', 'open']); + +const isActiveRequest = (request: ProcessedRequest) => { + return activeStatuses.has(request.status); +}; + +const getPrimaryBarClassName = (request: ProcessedRequest) => { + if (request.status === 'failed' || request.status === 'error') { + return 'bg-red-400'; + } + + switch (request.type) { + case 'websocket': + return 'bg-emerald-400'; + case 'sse': + return 'bg-amber-400'; + case 'http': + return 'bg-sky-400'; + } +}; + +const formatOffset = (milliseconds: number) => { + if (milliseconds < 1000) { + return `${Math.round(milliseconds)} ms`; + } + + if (milliseconds < 60000) { + return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)} s`; + } + + const minutes = Math.floor(milliseconds / 60000); + const seconds = Math.round((milliseconds % 60000) / 1000); + return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; +}; + +const getRequestEndTime = (request: ProcessedRequest, now: number) => { + if (typeof request.duration === 'number') { + return request.timestamp + Math.max(request.duration, 0); + } + + if (isActiveRequest(request)) { + return Math.max(now, request.timestamp); + } + + return request.timestamp; +}; + +const getTimelineTicks = (rangeDuration: number): TimelineTick[] => { + const targetStep = rangeDuration / TICK_TARGET_COUNT; + const step = + NICE_TICK_STEPS.find((candidate) => candidate >= targetStep) ?? + NICE_TICK_STEPS[NICE_TICK_STEPS.length - 1]; + const ticks: TimelineTick[] = []; + + for (let value = 0; value <= rangeDuration; value += step) { + ticks.push({ + label: formatOffset(value), + offsetPercent: (value / rangeDuration) * 100, + }); + } + + if ( + ticks.length === 0 || + ticks[ticks.length - 1].offsetPercent < 100 - Number.EPSILON + ) { + ticks.push({ + label: formatOffset(rangeDuration), + offsetPercent: 100, + }); + } + + return ticks; +}; + +const getTimelineRequests = (requests: ProcessedRequest[], now: number) => { + if (requests.length === 0) { + return { + rows: [], + rangeStart: 0, + rangeDuration: MIN_RANGE_MS, + }; + } + + const bounds = requests.reduce( + (result, request) => { + const endTime = getRequestEndTime(request, now); + + return { + start: Math.min(result.start, request.timestamp), + end: Math.max(result.end, endTime), + }; + }, + { + start: Number.POSITIVE_INFINITY, + end: Number.NEGATIVE_INFINITY, + }, + ); + + const rawRangeDuration = Math.max(bounds.end - bounds.start, MIN_RANGE_MS); + const rangePadding = Math.max(rawRangeDuration * 0.025, 25); + const rangeStart = bounds.start - rangePadding; + const rangeDuration = rawRangeDuration + rangePadding * 2; + const laneEndTimes = Array.from({ length: CHART_LANE_COUNT }, () => 0); + + const rows = [...requests] + .sort((a, b) => a.timestamp - b.timestamp) + .map((request): TimelineRequest => { + const startTime = request.timestamp; + const endTime = getRequestEndTime(request, now); + const duration = Math.max(endTime - startTime, 0); + const rawOffsetPercent = ((startTime - rangeStart) / rangeDuration) * 100; + const offsetPercent = Math.min(Math.max(rawOffsetPercent, 0), 99.35); + const rawWidthPercent = (duration / rangeDuration) * 100; + const widthPercent = Math.min( + Math.max(rawWidthPercent, MIN_VISIBLE_BAR_PERCENT), + 100 - offsetPercent, + ); + const ttfb = Math.min(Math.max(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 lane = availableLane === -1 ? 0 : availableLane; + laneEndTimes[lane] = endTime; + + return { + request, + startTime, + endTime, + offsetPercent, + widthPercent, + duration, + ttfbPercent, + receivePercent, + isActive: isActiveRequest(request), + lane, + }; + }); + + return { + rows, + rangeStart, + rangeDuration, + }; +}; + +const getStyle = ( + offsetPercent: number, + widthPercent: number, +): CSSProperties => ({ + left: `${offsetPercent}%`, + width: `${widthPercent}%`, +}); + +const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => { + return ( +
+ {ticks.map((tick) => ( +
+ ))} +
+ ); +}; + +const TimelineTrack = ({ + row, + isSelected, + onSelect, +}: { + row: TimelineRequest; + isSelected: boolean; + onSelect: (requestId: RequestId) => void; +}) => { + const primaryBarClassName = getPrimaryBarClassName(row.request); + const isSplitHttpBar = + row.request.type === 'http' && + row.ttfbPercent > 0 && + row.receivePercent > 0; + const top = CHART_LANE_TOP + row.lane * (CHART_LANE_HEIGHT + CHART_LANE_GAP); + const positionStyle = { + ...getStyle(row.offsetPercent, row.widthPercent), + top, + }; + const durationLabel = row.isActive + ? `${formatOffset(row.duration)}+` + : formatOffset(row.duration); + + return ( + + ); +}; + +export type NetworkTimelineProps = { + filter: FilterState; +}; + +export const NetworkTimeline = ({ filter }: NetworkTimelineProps) => { + const actions = useNetworkActivityActions(); + const processedRequests = useProcessedRequests(); + const selectedRequestId = useSelectedRequestId(); + const overrides = useOverrides(); + const [now, setNow] = useState(() => Date.now()); + + const filteredRequests = useMemo(() => { + return processedRequests.filter((request) => + matchesRequestFilter(request, filter, { + hasOverride: overrides.has(request.name), + }), + ); + }, [processedRequests, filter, overrides]); + + const hasActiveRequests = filteredRequests.some(isActiveRequest); + + useEffect(() => { + if (!hasActiveRequests) { + return; + } + + const interval = window.setInterval(() => { + setNow(Date.now()); + }, LIVE_REFRESH_MS); + + return () => window.clearInterval(interval); + }, [hasActiveRequests]); + + const timeline = useMemo(() => { + return getTimelineRequests(filteredRequests, now); + }, [filteredRequests, now]); + + const ticks = useMemo(() => { + return getTimelineTicks(timeline.rangeDuration); + }, [timeline.rangeDuration]); + + const onRequestSelect = (requestId: RequestId) => { + actions.setSelectedRequest(requestId); + }; + + return ( +
+ {filteredRequests.length === 0 ? ( +
+ No requests match the current filters +
+ ) : ( +
+ + +
+ + {ticks.map((tick) => ( +
+ {tick.label} +
+ ))} + + {timeline.rows.map((row) => ( + + ))} +
+ )} +
+ ); +}; 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/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/utils/requestFilters.ts b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts new file mode 100644 index 00000000..632a223d --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts @@ -0,0 +1,180 @@ +import type { FilterState } from '../components/FilterBar'; +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) { + 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.source, + request.type, + request.contentType, + domain, + path, + ] + .join(' ') + .toLowerCase(); + + return searchableFields.includes(searchText); +}; diff --git a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx index 95e37ef6..e1fe61d4 100644 --- a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx +++ b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx @@ -7,6 +7,7 @@ import { FilterBar, FilterState, } from '../components/FilterBar'; +import { NetworkTimeline } from '../components/NetworkTimeline'; import { NetworkActivityDevToolsClient } from '../../shared/client'; import { useNetworkActivityClientManagement, @@ -58,6 +59,7 @@ export const InspectorView = ({ client }: InspectorViewProps) => { hasSelectedRequest ? 'w-1/2' : 'w-full' } border-r border-gray-700 overflow-hidden`} > +
From 6ad6c25a4a5db14269c92d683daa5a751c64885d Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Thu, 14 May 2026 11:35:47 +0200 Subject: [PATCH 2/3] chore(network-activity-plugin): improve timeline --- .../src/ui/components/FilterBar.tsx | 58 +--- .../src/ui/components/NetworkTimeline.tsx | 327 +++++++----------- .../src/ui/components/RequestList.tsx | 191 +--------- .../src/ui/state/__tests__/store.test.ts | 77 +++++ .../src/ui/state/filter.ts | 49 +++ .../src/ui/state/store.ts | 26 +- .../ui/utils/__tests__/requestFilters.test.ts | 32 ++ .../ui/utils/__tests__/timelineModel.test.ts | 125 +++++++ .../src/ui/utils/requestFilters.ts | 9 +- .../src/ui/utils/timelineModel.ts | 311 +++++++++++++++++ .../src/ui/views/InspectorView.tsx | 26 +- 11 files changed, 776 insertions(+), 455 deletions(-) create mode 100644 packages/network-activity-plugin/src/ui/state/__tests__/store.test.ts create mode 100644 packages/network-activity-plugin/src/ui/state/filter.ts create mode 100644 packages/network-activity-plugin/src/ui/utils/__tests__/requestFilters.test.ts create mode 100644 packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts create mode 100644 packages/network-activity-plugin/src/ui/utils/timelineModel.ts 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) => ( { - return activeStatuses.has(request.status); -}; - const getPrimaryBarClassName = (request: ProcessedRequest) => { if (request.status === 'failed' || request.status === 'error') { - return 'bg-red-400'; + return REQUEST_TIMELINE_COLORS.error; } switch (request.type) { case 'websocket': - return 'bg-emerald-400'; + return REQUEST_TIMELINE_COLORS.websocket; case 'sse': - return 'bg-amber-400'; + return REQUEST_TIMELINE_COLORS.sse; case 'http': - return 'bg-sky-400'; - } -}; - -const formatOffset = (milliseconds: number) => { - if (milliseconds < 1000) { - return `${Math.round(milliseconds)} ms`; - } - - if (milliseconds < 60000) { - return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)} s`; - } - - const minutes = Math.floor(milliseconds / 60000); - const seconds = Math.round((milliseconds % 60000) / 1000); - return `${minutes}m ${seconds.toString().padStart(2, '0')}s`; -}; - -const getRequestEndTime = (request: ProcessedRequest, now: number) => { - if (typeof request.duration === 'number') { - return request.timestamp + Math.max(request.duration, 0); - } - - if (isActiveRequest(request)) { - return Math.max(now, request.timestamp); + return REQUEST_TIMELINE_COLORS.http; } - - return request.timestamp; -}; - -const getTimelineTicks = (rangeDuration: number): TimelineTick[] => { - const targetStep = rangeDuration / TICK_TARGET_COUNT; - const step = - NICE_TICK_STEPS.find((candidate) => candidate >= targetStep) ?? - NICE_TICK_STEPS[NICE_TICK_STEPS.length - 1]; - const ticks: TimelineTick[] = []; - - for (let value = 0; value <= rangeDuration; value += step) { - ticks.push({ - label: formatOffset(value), - offsetPercent: (value / rangeDuration) * 100, - }); - } - - if ( - ticks.length === 0 || - ticks[ticks.length - 1].offsetPercent < 100 - Number.EPSILON - ) { - ticks.push({ - label: formatOffset(rangeDuration), - offsetPercent: 100, - }); - } - - return ticks; -}; - -const getTimelineRequests = (requests: ProcessedRequest[], now: number) => { - if (requests.length === 0) { - return { - rows: [], - rangeStart: 0, - rangeDuration: MIN_RANGE_MS, - }; - } - - const bounds = requests.reduce( - (result, request) => { - const endTime = getRequestEndTime(request, now); - - return { - start: Math.min(result.start, request.timestamp), - end: Math.max(result.end, endTime), - }; - }, - { - start: Number.POSITIVE_INFINITY, - end: Number.NEGATIVE_INFINITY, - }, - ); - - const rawRangeDuration = Math.max(bounds.end - bounds.start, MIN_RANGE_MS); - const rangePadding = Math.max(rawRangeDuration * 0.025, 25); - const rangeStart = bounds.start - rangePadding; - const rangeDuration = rawRangeDuration + rangePadding * 2; - const laneEndTimes = Array.from({ length: CHART_LANE_COUNT }, () => 0); - - const rows = [...requests] - .sort((a, b) => a.timestamp - b.timestamp) - .map((request): TimelineRequest => { - const startTime = request.timestamp; - const endTime = getRequestEndTime(request, now); - const duration = Math.max(endTime - startTime, 0); - const rawOffsetPercent = ((startTime - rangeStart) / rangeDuration) * 100; - const offsetPercent = Math.min(Math.max(rawOffsetPercent, 0), 99.35); - const rawWidthPercent = (duration / rangeDuration) * 100; - const widthPercent = Math.min( - Math.max(rawWidthPercent, MIN_VISIBLE_BAR_PERCENT), - 100 - offsetPercent, - ); - const ttfb = Math.min(Math.max(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 lane = availableLane === -1 ? 0 : availableLane; - laneEndTimes[lane] = endTime; - - return { - request, - startTime, - endTime, - offsetPercent, - widthPercent, - duration, - ttfbPercent, - receivePercent, - isActive: isActiveRequest(request), - lane, - }; - }); - - return { - rows, - rangeStart, - rangeDuration, - }; }; const getStyle = ( @@ -212,12 +69,30 @@ const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => { ); }; +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, }: { - row: TimelineRequest; + row: TimelineRow; isSelected: boolean; onSelect: (requestId: RequestId) => void; }) => { @@ -226,65 +101,88 @@ const TimelineTrack = ({ row.request.type === 'http' && row.ttfbPercent > 0 && row.receivePercent > 0; - const top = CHART_LANE_TOP + row.lane * (CHART_LANE_HEIGHT + CHART_LANE_GAP); + const trackTop = getTimelineTrackTop(row.lane); + const barTop = getTimelineBarTopOffset(); const positionStyle = { ...getStyle(row.offsetPercent, row.widthPercent), - top, + top: trackTop, }; const durationLabel = row.isActive - ? `${formatOffset(row.duration)}+` - : formatOffset(row.duration); + ? `${formatTimelineOffset(row.duration)}+` + : formatTimelineOffset(row.duration); + const label = `${row.request.method} ${row.request.name} - ${durationLabel}`; return ( ); }; +const TimelineLegend = () => { + return ( +
+
+ {LEGEND_ITEMS.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ ); +}; + export type NetworkTimelineProps = { - filter: FilterState; + requests: ProcessedRequest[]; }; -export const NetworkTimeline = ({ filter }: NetworkTimelineProps) => { +export const NetworkTimeline = ({ requests }: NetworkTimelineProps) => { const actions = useNetworkActivityActions(); - const processedRequests = useProcessedRequests(); const selectedRequestId = useSelectedRequestId(); - const overrides = useOverrides(); const [now, setNow] = useState(() => Date.now()); - const filteredRequests = useMemo(() => { - return processedRequests.filter((request) => - matchesRequestFilter(request, filter, { - hasOverride: overrides.has(request.name), - }), - ); - }, [processedRequests, filter, overrides]); - - const hasActiveRequests = filteredRequests.some(isActiveRequest); + const hasActiveRequests = requests.some(isRequestActive); useEffect(() => { if (!hasActiveRequests) { @@ -293,18 +191,14 @@ export const NetworkTimeline = ({ filter }: NetworkTimelineProps) => { const interval = window.setInterval(() => { setNow(Date.now()); - }, LIVE_REFRESH_MS); + }, TIMELINE_LAYOUT.liveRefreshMs); return () => window.clearInterval(interval); }, [hasActiveRequests]); const timeline = useMemo(() => { - return getTimelineRequests(filteredRequests, now); - }, [filteredRequests, now]); - - const ticks = useMemo(() => { - return getTimelineTicks(timeline.rangeDuration); - }, [timeline.rangeDuration]); + return getTimelineModel(requests, now); + }, [requests, now]); const onRequestSelect = (requestId: RequestId) => { actions.setSelectedRequest(requestId); @@ -312,24 +206,28 @@ export const NetworkTimeline = ({ filter }: NetworkTimelineProps) => { return (
- {filteredRequests.length === 0 ? ( + + {requests.length === 0 ? (
No requests match the current filters
) : ( -
- +
+
- {ticks.map((tick) => ( + {timeline.ticks.map((tick) => (
{tick.label}
@@ -343,6 +241,13 @@ export const NetworkTimeline = ({ filter }: NetworkTimelineProps) => { onSelect={onRequestSelect} /> ))} + + {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/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/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..27ee5c72 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import type { ProcessedRequest } from '../../state/model'; +import { + formatTimelineOffset, + getTimelineModel, + getTimelineTicks, + isRequestActive, + 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); + }); +}); diff --git a/packages/network-activity-plugin/src/ui/utils/requestFilters.ts b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts index 632a223d..179c9f19 100644 --- a/packages/network-activity-plugin/src/ui/utils/requestFilters.ts +++ b/packages/network-activity-plugin/src/ui/utils/requestFilters.ts @@ -1,4 +1,4 @@ -import type { FilterState } from '../components/FilterBar'; +import type { FilterState } from '../state/filter'; import type { ProcessedRequest } from '../state/model'; import type { HttpMethod } from '../../shared/client'; @@ -25,7 +25,7 @@ const matchesStatusFilter = ( return true; } - if (!statusCode) { + if (statusCode === undefined) { return false; } @@ -95,7 +95,8 @@ export const matchesRequestFilter = ( if ( filter.advanced.methods.size > 0 && - (!isHttpMethod(request.method) || !filter.advanced.methods.has(request.method)) + (!isHttpMethod(request.method) || + !filter.advanced.methods.has(request.method)) ) { return false; } @@ -167,12 +168,14 @@ export const matchesRequestFilter = ( request.name, request.method, request.status, + request.httpStatus, request.source, request.type, request.contentType, domain, path, ] + .filter((value) => value !== undefined && value !== null) .join(' ') .toLowerCase(); 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..e2c193f7 --- /dev/null +++ b/packages/network-activity-plugin/src/ui/utils/timelineModel.ts @@ -0,0 +1,311 @@ +import type { ProcessedRequest } from '../state/model'; + +export const TIMELINE_LAYOUT = { + minVisibleBarPercent: 0.65, + minRangeMs: 1000, + liveRefreshMs: 1000, + maxRenderedRequests: 1000, + laneCount: 8, + laneHeightPx: 3, + laneGapPx: 9, + laneHitTargetHeightPx: 12, + rulerHeightPx: 28, + laneTopPx: 44, + laneBottomPaddingPx: 32, + tickTargetCount: 7, + minTickLabelGapPercent: 6, + rangePaddingRatio: 0.025, + minRangePaddingMs: 25, +} 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 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; +}; + +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) => { + return requests.reduce( + (result, request) => { + const endTime = getRequestEndTime(request, now); + + 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); + 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 = getRequestEndTime(request, now); + 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 e1fe61d4..f2332869 100644 --- a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx +++ b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx @@ -1,12 +1,8 @@ -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 { @@ -14,7 +10,11 @@ import { useHasSelectedRequest, useNetworkActivityActions, useOverrides, + useProcessedRequests, } from '../state/hooks'; +import { createDefaultFilter } from '../state/filter'; +import type { FilterState } from '../state/filter'; +import { matchesRequestFilter } from '../utils/requestFilters'; export type InspectorViewProps = { client: NetworkActivityDevToolsClient; @@ -25,10 +25,19 @@ export const InspectorView = ({ client }: InspectorViewProps) => { const clientManagement = useNetworkActivityClientManagement(); const hasSelectedRequest = useHasSelectedRequest(); const overrides = useOverrides(); + const processedRequests = useProcessedRequests(); const [filter, setFilter] = useState(() => createDefaultFilter(), ); + const filteredRequests = useMemo(() => { + return processedRequests.filter((request) => + matchesRequestFilter(request, filter, { + hasOverride: overrides.has(request.name), + }), + ); + }, [processedRequests, filter, overrides]); + useEffect(() => { if (!client) { return; @@ -53,14 +62,13 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
- {/* Request List */}
- - + +
{hasSelectedRequest && } From 9e20d3a60370a22399ed5bdc057098a99d9f77bc Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Tue, 19 May 2026 09:31:02 +0200 Subject: [PATCH 3/3] chore(network-activity-plugin): add selection filter --- .../src/ui/components/NetworkTimeline.tsx | 275 ++++++++++++++---- .../ui/utils/__tests__/timelineModel.test.ts | 45 +++ .../src/ui/utils/timelineModel.ts | 61 +++- .../src/ui/views/InspectorView.tsx | 26 +- 4 files changed, 344 insertions(+), 63 deletions(-) diff --git a/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx b/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx index 2a1ac0dc..d945afec 100644 --- a/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx +++ b/packages/network-activity-plugin/src/ui/components/NetworkTimeline.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react'; -import type { CSSProperties } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, PointerEvent } from 'react'; +import { X } from 'lucide-react'; import type { RequestId } from '../../shared/client'; import type { ProcessedRequest } from '../state/model'; import { @@ -14,37 +15,26 @@ import { isRequestActive, TIMELINE_LAYOUT, } from '../utils/timelineModel'; -import type { TimelineRow, TimelineTick } from '../utils/timelineModel'; +import type { + TimelineModel, + TimelineRangeSelection, + TimelineRow, + TimelineTick, +} from '../utils/timelineModel'; const REQUEST_TIMELINE_COLORS = { error: 'bg-red-400', - http: 'bg-sky-400', - websocket: 'bg-emerald-400', - sse: 'bg-amber-400', - httpTtfb: 'bg-lime-400', + primary: 'bg-gray-400', + active: 'bg-gray-500', + httpTtfb: 'bg-gray-200', } as const; -const LEGEND_ITEMS = [ - { label: 'HTTP', className: REQUEST_TIMELINE_COLORS.http }, - { label: 'WebSocket', className: REQUEST_TIMELINE_COLORS.websocket }, - { label: 'SSE', className: REQUEST_TIMELINE_COLORS.sse }, - { label: 'TTFB', className: REQUEST_TIMELINE_COLORS.httpTtfb }, - { label: 'Error', className: REQUEST_TIMELINE_COLORS.error }, -]; - const getPrimaryBarClassName = (request: ProcessedRequest) => { if (request.status === 'failed' || request.status === 'error') { return REQUEST_TIMELINE_COLORS.error; } - switch (request.type) { - case 'websocket': - return REQUEST_TIMELINE_COLORS.websocket; - case 'sse': - return REQUEST_TIMELINE_COLORS.sse; - case 'http': - return REQUEST_TIMELINE_COLORS.http; - } + return REQUEST_TIMELINE_COLORS.primary; }; const getStyle = ( @@ -61,7 +51,7 @@ const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => { {ticks.map((tick) => (
))} @@ -91,12 +81,16 @@ const TimelineTrack = ({ row, isSelected, onSelect, + shouldSuppressSelect, }: { row: TimelineRow; isSelected: boolean; onSelect: (requestId: RequestId) => void; + shouldSuppressSelect: () => boolean; }) => { - const primaryBarClassName = getPrimaryBarClassName(row.request); + const primaryBarClassName = row.isActive + ? REQUEST_TIMELINE_COLORS.active + : getPrimaryBarClassName(row.request); const isSplitHttpBar = row.request.type === 'http' && row.ttfbPercent > 0 && @@ -117,20 +111,29 @@ const TimelineTrack = ({ type="button" aria-label={label} title={label} - className={`absolute rounded-sm text-left transition-opacity hover:opacity-80 ${ - row.isActive ? 'animate-pulse' : '' - } ${ - isSelected ? 'outline outline-1 outline-offset-1 outline-blue-300' : '' - }`} + data-timeline-track="true" + className="absolute rounded-sm text-left transition-opacity hover:opacity-80" style={{ ...positionStyle, height: TIMELINE_LAYOUT.laneHitTargetHeightPx, }} - onClick={() => onSelect(row.request.id)} + onClick={(event) => { + if (shouldSuppressSelect()) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + onSelect(row.request.id); + }} > {isSplitHttpBar ? (
) : (
{ - return ( -
-
- {LEGEND_ITEMS.map((item) => ( -
- - {item.label} -
- ))} -
-
+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 }: NetworkTimelineProps) => { +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); @@ -204,17 +261,103 @@ export const NetworkTimeline = ({ requests }: NetworkTimelineProps) => { 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
) : (
@@ -233,17 +376,47 @@ export const NetworkTimeline = ({ requests }: NetworkTimelineProps) => {
))} + {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/utils/__tests__/timelineModel.test.ts b/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts index 27ee5c72..ba14d1fd 100644 --- a/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts +++ b/packages/network-activity-plugin/src/ui/utils/__tests__/timelineModel.test.ts @@ -3,8 +3,10 @@ import type { ProcessedRequest } from '../../state/model'; import { formatTimelineOffset, getTimelineModel, + getTimelineRequestEndTime, getTimelineTicks, isRequestActive, + requestOverlapsTimelineRange, TIMELINE_LAYOUT, } from '../timelineModel'; @@ -122,4 +124,47 @@ describe('timelineModel', () => { 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/timelineModel.ts b/packages/network-activity-plugin/src/ui/utils/timelineModel.ts index e2c193f7..a0bc06ad 100644 --- a/packages/network-activity-plugin/src/ui/utils/timelineModel.ts +++ b/packages/network-activity-plugin/src/ui/utils/timelineModel.ts @@ -6,16 +6,17 @@ export const TIMELINE_LAYOUT = { liveRefreshMs: 1000, maxRenderedRequests: 1000, laneCount: 8, - laneHeightPx: 3, - laneGapPx: 9, - laneHitTargetHeightPx: 12, - rulerHeightPx: 28, - laneTopPx: 44, - laneBottomPaddingPx: 32, + 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; @@ -29,6 +30,11 @@ export type TimelineTick = { offsetPercent: number; }; +export type TimelineRangeSelection = { + startTime: number; + endTime: number; +}; + export type TimelineRow = { request: ProcessedRequest; offsetPercent: number; @@ -141,6 +147,37 @@ export const getRequestEndTime = (request: ProcessedRequest, now: number) => { 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)); @@ -190,10 +227,14 @@ export const getTimelineTicks = ( return ticks; }; -const getTimelineBounds = (requests: ProcessedRequest[], now: number) => { +const getTimelineBounds = ( + requests: ProcessedRequest[], + now: number, + layout: TimelineLayout, +) => { return requests.reduce( (result, request) => { - const endTime = getRequestEndTime(request, now); + const endTime = getTimelineRequestEndTime(request, now, layout); return { start: Math.min(result.start, request.timestamp), @@ -246,7 +287,7 @@ export const getTimelineModel = ( }; } - const bounds = getTimelineBounds(renderableRequests, now); + const bounds = getTimelineBounds(renderableRequests, now, layout); const rawRangeDuration = Math.max( bounds.end - bounds.start, layout.minRangeMs, @@ -263,7 +304,7 @@ export const getTimelineModel = ( .sort((a, b) => a.timestamp - b.timestamp) .map((request): TimelineRow => { const startTime = request.timestamp; - const endTime = getRequestEndTime(request, now); + const endTime = getTimelineRequestEndTime(request, now, layout); const duration = Math.max(endTime - startTime, 0); const offsetPercent = clamp( ((startTime - rangeStart) / rangeDuration) * 100, diff --git a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx index f2332869..6a992323 100644 --- a/packages/network-activity-plugin/src/ui/views/InspectorView.tsx +++ b/packages/network-activity-plugin/src/ui/views/InspectorView.tsx @@ -15,6 +15,10 @@ import { 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; @@ -29,6 +33,8 @@ export const InspectorView = ({ client }: InspectorViewProps) => { const [filter, setFilter] = useState(() => createDefaultFilter(), ); + const [timelineSelection, setTimelineSelection] = + useState(null); const filteredRequests = useMemo(() => { return processedRequests.filter((request) => @@ -38,6 +44,17 @@ export const InspectorView = ({ client }: InspectorViewProps) => { ); }, [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) { return; @@ -67,8 +84,13 @@ export const InspectorView = ({ client }: InspectorViewProps) => { hasSelectedRequest ? 'w-1/2' : 'w-full' } border-r border-gray-700 overflow-hidden`} > - - + +
{hasSelectedRequest && }