@rozenite/network-activity-plugin 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,352 @@
1
+ import type { ProcessedRequest } from '../state/model';
2
+
3
+ export const TIMELINE_LAYOUT = {
4
+ minVisibleBarPercent: 0.65,
5
+ minRangeMs: 1000,
6
+ liveRefreshMs: 1000,
7
+ maxRenderedRequests: 1000,
8
+ laneCount: 8,
9
+ laneHeightPx: 2,
10
+ laneGapPx: 6,
11
+ laneHitTargetHeightPx: 8,
12
+ rulerHeightPx: 22,
13
+ laneTopPx: 32,
14
+ laneBottomPaddingPx: 18,
15
+ tickTargetCount: 7,
16
+ minTickLabelGapPercent: 6,
17
+ rangePaddingRatio: 0.025,
18
+ minRangePaddingMs: 25,
19
+ streamingRequestMaxDurationMs: 5000,
20
+ } as const;
21
+
22
+ const NICE_TICK_FACTORS = [1, 2, 2.5, 5, 10] as const;
23
+
24
+ type TimelineLayout = {
25
+ [Key in keyof typeof TIMELINE_LAYOUT]: number;
26
+ };
27
+
28
+ export type TimelineTick = {
29
+ label: string;
30
+ offsetPercent: number;
31
+ };
32
+
33
+ export type TimelineRangeSelection = {
34
+ startTime: number;
35
+ endTime: number;
36
+ };
37
+
38
+ export type TimelineRow = {
39
+ request: ProcessedRequest;
40
+ offsetPercent: number;
41
+ widthPercent: number;
42
+ duration: number;
43
+ ttfbPercent: number;
44
+ receivePercent: number;
45
+ isActive: boolean;
46
+ lane: number;
47
+ isOverflowingLane: boolean;
48
+ };
49
+
50
+ export type TimelineModel = {
51
+ rows: TimelineRow[];
52
+ ticks: TimelineTick[];
53
+ rangeStart: number;
54
+ rangeDuration: number;
55
+ chartHeight: number;
56
+ totalRequestCount: number;
57
+ hiddenRequestCount: number;
58
+ };
59
+
60
+ const ACTIVE_HTTP_STATUSES = new Set<ProcessedRequest['status']>([
61
+ 'pending',
62
+ 'loading',
63
+ ]);
64
+ const ACTIVE_WEBSOCKET_STATUSES = new Set<ProcessedRequest['status']>([
65
+ 'connecting',
66
+ 'open',
67
+ 'closing',
68
+ ]);
69
+ const ACTIVE_SSE_STATUSES = new Set<ProcessedRequest['status']>([
70
+ 'connecting',
71
+ 'open',
72
+ ]);
73
+
74
+ const clamp = (value: number, minimum: number, maximum: number) => {
75
+ return Math.min(Math.max(value, minimum), maximum);
76
+ };
77
+
78
+ export const getTimelineChartHeight = (
79
+ layout: TimelineLayout = TIMELINE_LAYOUT,
80
+ ) => {
81
+ return (
82
+ layout.laneTopPx +
83
+ layout.laneCount * layout.laneHeightPx +
84
+ (layout.laneCount - 1) * layout.laneGapPx +
85
+ layout.laneBottomPaddingPx
86
+ );
87
+ };
88
+
89
+ export const getTimelineLaneTop = (
90
+ lane: number,
91
+ layout: TimelineLayout = TIMELINE_LAYOUT,
92
+ ) => {
93
+ return lane * (layout.laneHeightPx + layout.laneGapPx) + layout.laneTopPx;
94
+ };
95
+
96
+ export const getTimelineTrackTop = (
97
+ lane: number,
98
+ layout: TimelineLayout = TIMELINE_LAYOUT,
99
+ ) => {
100
+ const visualBarTop = getTimelineLaneTop(lane, layout);
101
+ return (
102
+ visualBarTop - (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2
103
+ );
104
+ };
105
+
106
+ export const getTimelineBarTopOffset = (
107
+ layout: TimelineLayout = TIMELINE_LAYOUT,
108
+ ) => {
109
+ return (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2;
110
+ };
111
+
112
+ export const isRequestActive = (request: ProcessedRequest) => {
113
+ switch (request.type) {
114
+ case 'http':
115
+ return ACTIVE_HTTP_STATUSES.has(request.status);
116
+ case 'websocket':
117
+ return ACTIVE_WEBSOCKET_STATUSES.has(request.status);
118
+ case 'sse':
119
+ return ACTIVE_SSE_STATUSES.has(request.status);
120
+ }
121
+ };
122
+
123
+ export const formatTimelineOffset = (milliseconds: number) => {
124
+ if (milliseconds < 1000) {
125
+ return `${Math.round(milliseconds)} ms`;
126
+ }
127
+
128
+ if (milliseconds < 60000) {
129
+ return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)} s`;
130
+ }
131
+
132
+ const totalSeconds = Math.round(milliseconds / 1000);
133
+ const minutes = Math.floor(totalSeconds / 60);
134
+ const seconds = totalSeconds % 60;
135
+ return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
136
+ };
137
+
138
+ export const getRequestEndTime = (request: ProcessedRequest, now: number) => {
139
+ if (typeof request.duration === 'number') {
140
+ return request.timestamp + Math.max(request.duration, 0);
141
+ }
142
+
143
+ if (isRequestActive(request)) {
144
+ return Math.max(now, request.timestamp);
145
+ }
146
+
147
+ return request.timestamp;
148
+ };
149
+
150
+ export const getTimelineRequestEndTime = (
151
+ request: ProcessedRequest,
152
+ now: number,
153
+ layout: TimelineLayout = TIMELINE_LAYOUT,
154
+ ) => {
155
+ const endTime = getRequestEndTime(request, now);
156
+
157
+ if (request.type !== 'websocket' && request.type !== 'sse') {
158
+ return endTime;
159
+ }
160
+
161
+ return Math.min(
162
+ endTime,
163
+ request.timestamp + layout.streamingRequestMaxDurationMs,
164
+ );
165
+ };
166
+
167
+ export const requestOverlapsTimelineRange = (
168
+ request: ProcessedRequest,
169
+ range: TimelineRangeSelection,
170
+ now: number,
171
+ layout: TimelineLayout = TIMELINE_LAYOUT,
172
+ ) => {
173
+ const rangeStart = Math.min(range.startTime, range.endTime);
174
+ const rangeEnd = Math.max(range.startTime, range.endTime);
175
+ const requestStart = request.timestamp;
176
+ const requestEnd = getTimelineRequestEndTime(request, now, layout);
177
+
178
+ return requestStart <= rangeEnd && requestEnd >= rangeStart;
179
+ };
180
+
181
+ const getNiceTickStep = (rangeDuration: number, targetTickCount: number) => {
182
+ const targetStep = rangeDuration / targetTickCount;
183
+ const exponent = Math.floor(Math.log10(targetStep));
184
+ const magnitude = 10 ** exponent;
185
+ const normalizedStep = targetStep / magnitude;
186
+ const factor =
187
+ NICE_TICK_FACTORS.find((candidate) => candidate >= normalizedStep) ??
188
+ NICE_TICK_FACTORS[NICE_TICK_FACTORS.length - 1];
189
+
190
+ return factor * magnitude;
191
+ };
192
+
193
+ export const getTimelineTicks = (
194
+ rangeDuration: number,
195
+ layout: TimelineLayout = TIMELINE_LAYOUT,
196
+ ): TimelineTick[] => {
197
+ const step = getNiceTickStep(rangeDuration, layout.tickTargetCount);
198
+ const ticks: TimelineTick[] = [];
199
+
200
+ for (let value = 0; value <= rangeDuration; value += step) {
201
+ ticks.push({
202
+ label: formatTimelineOffset(value),
203
+ offsetPercent: (value / rangeDuration) * 100,
204
+ });
205
+ }
206
+
207
+ if (
208
+ ticks.length === 0 ||
209
+ ticks[ticks.length - 1].offsetPercent < 100 - Number.EPSILON
210
+ ) {
211
+ const finalTick = {
212
+ label: formatTimelineOffset(rangeDuration),
213
+ offsetPercent: 100,
214
+ };
215
+ const previousTick = ticks[ticks.length - 1];
216
+
217
+ if (
218
+ !previousTick ||
219
+ (finalTick.label !== previousTick.label &&
220
+ finalTick.offsetPercent - previousTick.offsetPercent >=
221
+ layout.minTickLabelGapPercent)
222
+ ) {
223
+ ticks.push(finalTick);
224
+ }
225
+ }
226
+
227
+ return ticks;
228
+ };
229
+
230
+ const getTimelineBounds = (
231
+ requests: ProcessedRequest[],
232
+ now: number,
233
+ layout: TimelineLayout,
234
+ ) => {
235
+ return requests.reduce(
236
+ (result, request) => {
237
+ const endTime = getTimelineRequestEndTime(request, now, layout);
238
+
239
+ return {
240
+ start: Math.min(result.start, request.timestamp),
241
+ end: Math.max(result.end, endTime),
242
+ };
243
+ },
244
+ {
245
+ start: Number.POSITIVE_INFINITY,
246
+ end: Number.NEGATIVE_INFINITY,
247
+ },
248
+ );
249
+ };
250
+
251
+ const getEarliestLaneIndex = (laneEndTimes: number[]) => {
252
+ return laneEndTimes.reduce((earliestIndex, laneEndTime, index) => {
253
+ return laneEndTime < laneEndTimes[earliestIndex] ? index : earliestIndex;
254
+ }, 0);
255
+ };
256
+
257
+ const getRenderableRequests = (
258
+ requests: ProcessedRequest[],
259
+ layout: TimelineLayout,
260
+ ) => {
261
+ if (requests.length <= layout.maxRenderedRequests) {
262
+ return requests;
263
+ }
264
+
265
+ return [...requests]
266
+ .sort((a, b) => b.timestamp - a.timestamp)
267
+ .slice(0, layout.maxRenderedRequests);
268
+ };
269
+
270
+ export const getTimelineModel = (
271
+ requests: ProcessedRequest[],
272
+ now: number,
273
+ layout: TimelineLayout = TIMELINE_LAYOUT,
274
+ ): TimelineModel => {
275
+ const renderableRequests = getRenderableRequests(requests, layout);
276
+ const hiddenRequestCount = requests.length - renderableRequests.length;
277
+
278
+ if (renderableRequests.length === 0) {
279
+ return {
280
+ rows: [],
281
+ ticks: getTimelineTicks(layout.minRangeMs, layout),
282
+ rangeStart: 0,
283
+ rangeDuration: layout.minRangeMs,
284
+ chartHeight: getTimelineChartHeight(layout),
285
+ totalRequestCount: requests.length,
286
+ hiddenRequestCount,
287
+ };
288
+ }
289
+
290
+ const bounds = getTimelineBounds(renderableRequests, now, layout);
291
+ const rawRangeDuration = Math.max(
292
+ bounds.end - bounds.start,
293
+ layout.minRangeMs,
294
+ );
295
+ const rangePadding = Math.max(
296
+ rawRangeDuration * layout.rangePaddingRatio,
297
+ layout.minRangePaddingMs,
298
+ );
299
+ const rangeStart = bounds.start - rangePadding;
300
+ const rangeDuration = rawRangeDuration + rangePadding * 2;
301
+ const laneEndTimes = Array.from({ length: layout.laneCount }, () => 0);
302
+
303
+ const rows = [...renderableRequests]
304
+ .sort((a, b) => a.timestamp - b.timestamp)
305
+ .map((request): TimelineRow => {
306
+ const startTime = request.timestamp;
307
+ const endTime = getTimelineRequestEndTime(request, now, layout);
308
+ const duration = Math.max(endTime - startTime, 0);
309
+ const offsetPercent = clamp(
310
+ ((startTime - rangeStart) / rangeDuration) * 100,
311
+ 0,
312
+ 100 - layout.minVisibleBarPercent,
313
+ );
314
+ const widthPercent = Math.min(
315
+ Math.max((duration / rangeDuration) * 100, layout.minVisibleBarPercent),
316
+ 100 - offsetPercent,
317
+ );
318
+ const ttfb = clamp(request.ttfb ?? 0, 0, duration);
319
+ const ttfbPercent = duration === 0 ? 0 : (ttfb / duration) * 100;
320
+ const receivePercent = Math.max(100 - ttfbPercent, 0);
321
+ const availableLane = laneEndTimes.findIndex(
322
+ (laneEndTime) => laneEndTime <= startTime,
323
+ );
324
+ const isOverflowingLane = availableLane === -1;
325
+ const lane = isOverflowingLane
326
+ ? getEarliestLaneIndex(laneEndTimes)
327
+ : availableLane;
328
+ laneEndTimes[lane] = Math.max(laneEndTimes[lane], endTime);
329
+
330
+ return {
331
+ request,
332
+ offsetPercent,
333
+ widthPercent,
334
+ duration,
335
+ ttfbPercent,
336
+ receivePercent,
337
+ isActive: isRequestActive(request),
338
+ lane,
339
+ isOverflowingLane,
340
+ };
341
+ });
342
+
343
+ return {
344
+ rows,
345
+ ticks: getTimelineTicks(rangeDuration, layout),
346
+ rangeStart,
347
+ rangeDuration,
348
+ chartHeight: getTimelineChartHeight(layout),
349
+ totalRequestCount: requests.length,
350
+ hiddenRequestCount,
351
+ };
352
+ };
@@ -1,19 +1,24 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { Toolbar } from '../components/Toolbar';
3
3
  import { RequestList } from '../components/RequestList';
4
4
  import { SidePanel } from '../components/SidePanel';
5
- import {
6
- createDefaultFilter,
7
- FilterBar,
8
- FilterState,
9
- } from '../components/FilterBar';
5
+ import { FilterBar } from '../components/FilterBar';
6
+ import { NetworkTimeline } from '../components/NetworkTimeline';
10
7
  import { NetworkActivityDevToolsClient } from '../../shared/client';
11
8
  import {
12
9
  useNetworkActivityClientManagement,
13
10
  useHasSelectedRequest,
14
11
  useNetworkActivityActions,
15
12
  useOverrides,
13
+ useProcessedRequests,
16
14
  } from '../state/hooks';
15
+ import { createDefaultFilter } from '../state/filter';
16
+ import type { FilterState } from '../state/filter';
17
+ import { matchesRequestFilter } from '../utils/requestFilters';
18
+ import {
19
+ requestOverlapsTimelineRange,
20
+ type TimelineRangeSelection,
21
+ } from '../utils/timelineModel';
17
22
 
18
23
  export type InspectorViewProps = {
19
24
  client: NetworkActivityDevToolsClient;
@@ -24,9 +29,31 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
24
29
  const clientManagement = useNetworkActivityClientManagement();
25
30
  const hasSelectedRequest = useHasSelectedRequest();
26
31
  const overrides = useOverrides();
32
+ const processedRequests = useProcessedRequests();
27
33
  const [filter, setFilter] = useState<FilterState>(() =>
28
34
  createDefaultFilter(),
29
35
  );
36
+ const [timelineSelection, setTimelineSelection] =
37
+ useState<TimelineRangeSelection | null>(null);
38
+
39
+ const filteredRequests = useMemo(() => {
40
+ return processedRequests.filter((request) =>
41
+ matchesRequestFilter(request, filter, {
42
+ hasOverride: overrides.has(request.name),
43
+ }),
44
+ );
45
+ }, [processedRequests, filter, overrides]);
46
+
47
+ const visibleRequests = useMemo(() => {
48
+ if (!timelineSelection) {
49
+ return filteredRequests;
50
+ }
51
+
52
+ const now = Date.now();
53
+ return filteredRequests.filter((request) =>
54
+ requestOverlapsTimelineRange(request, timelineSelection, now),
55
+ );
56
+ }, [filteredRequests, timelineSelection]);
30
57
 
31
58
  useEffect(() => {
32
59
  if (!client) {
@@ -52,13 +79,18 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
52
79
  <FilterBar filter={filter} onFilterChange={setFilter} />
53
80
 
54
81
  <div className="flex flex-1 overflow-hidden">
55
- {/* Request List */}
56
82
  <div
57
83
  className={`flex flex-col ${
58
84
  hasSelectedRequest ? 'w-1/2' : 'w-full'
59
85
  } border-r border-gray-700 overflow-hidden`}
60
86
  >
61
- <RequestList filter={filter} />
87
+ <NetworkTimeline
88
+ requests={filteredRequests}
89
+ selection={timelineSelection}
90
+ filteredRequestCount={visibleRequests.length}
91
+ onSelectionChange={setTimelineSelection}
92
+ />
93
+ <RequestList requests={visibleRequests} />
62
94
  </div>
63
95
 
64
96
  {hasSelectedRequest && <SidePanel />}