@rozenite/network-activity-plugin 1.9.0 → 1.11.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/devtools/App.html +2 -2
  3. package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
  4. package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
  5. package/dist/react-native/chunks/boot-recording.cjs +138 -14
  6. package/dist/react-native/chunks/boot-recording.js +138 -14
  7. package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
  8. package/dist/react-native/chunks/get-nitro-module.js +4 -1
  9. package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
  10. package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
  11. package/dist/react-native/index.d.ts +37 -1
  12. package/dist/rozenite.json +1 -1
  13. package/dist/sdk/index.d.ts +37 -1
  14. package/package.json +12 -7
  15. package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
  16. package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
  17. package/src/react-native/http/http-utils.ts +208 -25
  18. package/src/react-native/network-inspector.ts +2 -2
  19. package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
  20. package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
  21. package/src/shared/http-events.ts +40 -1
  22. package/src/ui/components/CodeBlock.tsx +45 -1
  23. package/src/ui/components/FilterBar.tsx +337 -61
  24. package/src/ui/components/HexView.tsx +54 -0
  25. package/src/ui/components/MetadataCard.tsx +95 -0
  26. package/src/ui/components/NetworkTimeline.tsx +422 -0
  27. package/src/ui/components/RequestList.tsx +19 -40
  28. package/src/ui/components/SidePanel.tsx +42 -1
  29. package/src/ui/components/Toolbar.tsx +13 -1
  30. package/src/ui/components/ViewToggle.tsx +44 -0
  31. package/src/ui/components/XmlTree.tsx +160 -0
  32. package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
  33. package/src/ui/components/__tests__/HexView.test.tsx +41 -0
  34. package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
  35. package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
  36. package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
  37. package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
  38. package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
  39. package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
  40. package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
  41. package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
  42. package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
  43. package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
  44. package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
  45. package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
  46. package/src/ui/response-renderers/binary-too-large.tsx +36 -0
  47. package/src/ui/response-renderers/binary.tsx +31 -0
  48. package/src/ui/response-renderers/empty.tsx +14 -0
  49. package/src/ui/response-renderers/html.tsx +36 -0
  50. package/src/ui/response-renderers/image.tsx +37 -0
  51. package/src/ui/response-renderers/index.ts +55 -0
  52. package/src/ui/response-renderers/json.tsx +40 -0
  53. package/src/ui/response-renderers/svg.tsx +27 -0
  54. package/src/ui/response-renderers/text-fallback.tsx +14 -0
  55. package/src/ui/response-renderers/types.ts +38 -0
  56. package/src/ui/response-renderers/unknown.tsx +18 -0
  57. package/src/ui/response-renderers/xml.tsx +46 -0
  58. package/src/ui/state/__tests__/store.test.ts +77 -0
  59. package/src/ui/state/derived.ts +14 -0
  60. package/src/ui/state/filter.ts +49 -0
  61. package/src/ui/state/hooks.ts +2 -2
  62. package/src/ui/state/model.ts +7 -1
  63. package/src/ui/state/store.ts +63 -4
  64. package/src/ui/tabs/InitiatorTab.tsx +230 -0
  65. package/src/ui/tabs/ResponseTab.tsx +80 -97
  66. package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
  67. package/src/ui/utils/__tests__/download.test.ts +115 -0
  68. package/src/ui/utils/__tests__/hex.test.ts +84 -0
  69. package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
  70. package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
  71. package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
  72. package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
  73. package/src/ui/utils/download.ts +161 -0
  74. package/src/ui/utils/hex.ts +59 -0
  75. package/src/ui/utils/initiator.ts +136 -0
  76. package/src/ui/utils/requestFilters.ts +183 -0
  77. package/src/ui/utils/sessionExport.ts +185 -0
  78. package/src/ui/utils/symbolication.ts +248 -0
  79. package/src/ui/utils/timelineModel.ts +352 -0
  80. package/src/ui/views/InspectorView.tsx +43 -8
  81. package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
  82. package/src/utils/getContentTypeMimeType.ts +14 -0
  83. package/vite.config.ts +5 -1
  84. package/vitest.setup.ts +31 -0
@@ -0,0 +1,248 @@
1
+ import type { Initiator, InitiatorStackFrame } from '../../shared/client';
2
+
3
+ type ReactNativeStackFrame = {
4
+ methodName: string;
5
+ file: string | null | undefined;
6
+ lineNumber: number | null | undefined;
7
+ column: number | null | undefined;
8
+ collapse?: boolean;
9
+ };
10
+
11
+ type SymbolicatedStackTrace = {
12
+ stack: ReadonlyArray<ReactNativeStackFrame>;
13
+ codeFrame?: Initiator['codeFrame'];
14
+ };
15
+
16
+ type SymbolicateStackTrace = (
17
+ stack: ReadonlyArray<ReactNativeStackFrame>,
18
+ ) => Promise<SymbolicatedStackTrace>;
19
+
20
+ const normalizeFunctionName = (functionName?: string) => {
21
+ const trimmedFunctionName = functionName?.trim();
22
+
23
+ return trimmedFunctionName &&
24
+ trimmedFunctionName !== '<anonymous>' &&
25
+ trimmedFunctionName !== 'anonymous' &&
26
+ trimmedFunctionName !== '<unknown>'
27
+ ? trimmedFunctionName
28
+ : undefined;
29
+ };
30
+
31
+ const getGeneratedFrameLocation = (frame: InitiatorStackFrame) => ({
32
+ url: frame.generatedUrl ?? frame.url,
33
+ lineNumber: frame.generatedLineNumber ?? frame.lineNumber,
34
+ columnNumber: frame.generatedColumnNumber ?? frame.columnNumber,
35
+ });
36
+
37
+ const isGeneratedBundleUrl = (url: string) =>
38
+ /[^/]+\.bundle(?:[/?#]|$)/.test(url);
39
+
40
+ const canSymbolicateStack = (stack?: InitiatorStackFrame[]) =>
41
+ stack?.some((frame) =>
42
+ getGeneratedFrameLocation(frame).url?.startsWith('http'),
43
+ ) ?? false;
44
+
45
+ const toReactNativeStackFrame = (
46
+ frame: InitiatorStackFrame,
47
+ ): ReactNativeStackFrame | null => {
48
+ const generatedLocation = getGeneratedFrameLocation(frame);
49
+
50
+ if (!generatedLocation.url) {
51
+ return null;
52
+ }
53
+
54
+ return {
55
+ methodName: frame.functionName ?? '<anonymous>',
56
+ file: generatedLocation.url,
57
+ lineNumber: generatedLocation.lineNumber,
58
+ column: generatedLocation.columnNumber,
59
+ };
60
+ };
61
+
62
+ const fromSymbolicatedStackFrame = (
63
+ frame: ReactNativeStackFrame,
64
+ generatedFrame: InitiatorStackFrame = {},
65
+ ): InitiatorStackFrame => {
66
+ const generatedLocation = getGeneratedFrameLocation(generatedFrame);
67
+ const sourceUrl =
68
+ frame.file &&
69
+ frame.file !== generatedLocation.url &&
70
+ !isGeneratedBundleUrl(frame.file)
71
+ ? frame.file
72
+ : undefined;
73
+
74
+ return {
75
+ functionName:
76
+ normalizeFunctionName(frame.methodName) ?? generatedFrame.functionName,
77
+ url: sourceUrl,
78
+ lineNumber: sourceUrl ? (frame.lineNumber ?? undefined) : undefined,
79
+ columnNumber: sourceUrl ? (frame.column ?? undefined) : undefined,
80
+ generatedUrl: generatedLocation.url,
81
+ generatedLineNumber: generatedLocation.lineNumber,
82
+ generatedColumnNumber: generatedLocation.columnNumber,
83
+ isCollapsed: frame.collapse,
84
+ };
85
+ };
86
+
87
+ const getComparableSourcePath = (url?: string) =>
88
+ url?.split(/[?#]/)[0].replace(/^file:\/\//, '');
89
+
90
+ const ANSI_SEQUENCE_PATTERN = new RegExp(
91
+ [
92
+ '[\\u001b\\u009b][[\\]()#;?]*',
93
+ '(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)',
94
+ '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))',
95
+ ].join(''),
96
+ 'g',
97
+ );
98
+
99
+ const stripAnsiSequences = (value: string) =>
100
+ // Metro returns code frames formatted for terminals. DevTools renders them
101
+ // as plain text, so terminal control sequences need to be removed.
102
+ value.replace(ANSI_SEQUENCE_PATTERN, '');
103
+
104
+ const sanitizeCodeFrame = (
105
+ codeFrame: Initiator['codeFrame'] | undefined,
106
+ ) => {
107
+ if (!codeFrame) {
108
+ return null;
109
+ }
110
+
111
+ return {
112
+ ...codeFrame,
113
+ content: stripAnsiSequences(codeFrame.content),
114
+ };
115
+ };
116
+
117
+ const isSameSourcePath = (left?: string, right?: string) => {
118
+ const leftPath = getComparableSourcePath(left);
119
+ const rightPath = getComparableSourcePath(right);
120
+
121
+ if (!leftPath || !rightPath) {
122
+ return false;
123
+ }
124
+
125
+ return leftPath.endsWith(rightPath) || rightPath.endsWith(leftPath);
126
+ };
127
+
128
+ const getSourceFrameForCodeFrame = (
129
+ stack: InitiatorStackFrame[],
130
+ codeFrame: Initiator['codeFrame'] | undefined,
131
+ ) => {
132
+ if (!codeFrame?.fileName) {
133
+ return null;
134
+ }
135
+
136
+ return (
137
+ stack.find((frame) => isSameSourcePath(codeFrame.fileName, frame.url)) ??
138
+ null
139
+ );
140
+ };
141
+
142
+ const getCodeFrameForSourceFrame = (
143
+ codeFrame: Initiator['codeFrame'] | undefined,
144
+ sourceFrame: InitiatorStackFrame | undefined,
145
+ ) => {
146
+ if (!codeFrame || !isSameSourcePath(codeFrame.fileName, sourceFrame?.url)) {
147
+ return null;
148
+ }
149
+
150
+ return sanitizeCodeFrame(codeFrame);
151
+ };
152
+
153
+ const getPreferredSourceFrame = (
154
+ stack: InitiatorStackFrame[],
155
+ codeFrame: Initiator['codeFrame'] | undefined,
156
+ ) =>
157
+ getSourceFrameForCodeFrame(stack, codeFrame) ??
158
+ stack.find((frame) => frame.url && !frame.isCollapsed) ??
159
+ stack.find((frame) => frame.url) ??
160
+ stack[0];
161
+
162
+ const getSymbolicationEndpoint = () => {
163
+ if (typeof window === 'undefined') {
164
+ throw new Error('Unable to resolve Metro symbolication endpoint');
165
+ }
166
+
167
+ return new URL('/symbolicate', window.location.origin).toString();
168
+ };
169
+
170
+ export const symbolicateStackTraceWithMetro: SymbolicateStackTrace = async (
171
+ stack,
172
+ ) => {
173
+ const response = await fetch(getSymbolicationEndpoint(), {
174
+ method: 'POST',
175
+ headers: {
176
+ 'Content-Type': 'application/json',
177
+ },
178
+ body: JSON.stringify({ stack }),
179
+ });
180
+
181
+ if (!response.ok) {
182
+ throw new Error(
183
+ `Metro symbolication failed with status ${response.status}`,
184
+ );
185
+ }
186
+
187
+ return response.json() as Promise<SymbolicatedStackTrace>;
188
+ };
189
+
190
+ export const symbolicateInitiator = async (
191
+ initiator: Initiator,
192
+ symbolicateStackTrace: SymbolicateStackTrace = symbolicateStackTraceWithMetro,
193
+ ): Promise<Initiator | null> => {
194
+ if (!canSymbolicateStack(initiator.stack)) {
195
+ return null;
196
+ }
197
+
198
+ const generatedStackFrames =
199
+ initiator.stack
200
+ ?.map(toReactNativeStackFrame)
201
+ .filter((frame): frame is ReactNativeStackFrame => frame !== null) ?? [];
202
+
203
+ if (generatedStackFrames.length === 0) {
204
+ return null;
205
+ }
206
+
207
+ try {
208
+ const symbolicatedStackTrace =
209
+ await symbolicateStackTrace(generatedStackFrames);
210
+
211
+ const symbolicatedStack = symbolicatedStackTrace.stack.map((frame, index) =>
212
+ fromSymbolicatedStackFrame(frame, initiator.stack?.[index]),
213
+ );
214
+ const sourceFrame = getPreferredSourceFrame(
215
+ symbolicatedStack,
216
+ symbolicatedStackTrace.codeFrame,
217
+ );
218
+ const hasSourceMappedFrame = symbolicatedStack.some((frame) => frame.url);
219
+
220
+ return {
221
+ ...initiator,
222
+ type: sourceFrame?.url ? 'script' : initiator.type,
223
+ functionName: sourceFrame?.functionName,
224
+ url: sourceFrame?.url,
225
+ lineNumber: sourceFrame?.lineNumber,
226
+ columnNumber: sourceFrame?.columnNumber,
227
+ generatedUrl: sourceFrame?.generatedUrl ?? initiator.generatedUrl,
228
+ generatedLineNumber:
229
+ sourceFrame?.generatedLineNumber ?? initiator.generatedLineNumber,
230
+ generatedColumnNumber:
231
+ sourceFrame?.generatedColumnNumber ?? initiator.generatedColumnNumber,
232
+ stack: symbolicatedStack,
233
+ codeFrame: getCodeFrameForSourceFrame(
234
+ symbolicatedStackTrace.codeFrame,
235
+ sourceFrame,
236
+ ),
237
+ symbolicationStatus: hasSourceMappedFrame ? 'complete' : 'unavailable',
238
+ symbolicationError: undefined,
239
+ };
240
+ } catch (error) {
241
+ return {
242
+ ...initiator,
243
+ symbolicationStatus: 'failed',
244
+ symbolicationError:
245
+ error instanceof Error ? error.message : 'Unable to symbolicate stack',
246
+ };
247
+ }
248
+ };
@@ -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,15 +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 { FilterBar, FilterState } from '../components/FilterBar';
5
+ import { FilterBar } from '../components/FilterBar';
6
+ import { NetworkTimeline } from '../components/NetworkTimeline';
6
7
  import { NetworkActivityDevToolsClient } from '../../shared/client';
7
8
  import {
8
9
  useNetworkActivityClientManagement,
9
10
  useHasSelectedRequest,
10
11
  useNetworkActivityActions,
11
12
  useOverrides,
13
+ useProcessedRequests,
12
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';
13
22
 
14
23
  export type InspectorViewProps = {
15
24
  client: NetworkActivityDevToolsClient;
@@ -20,10 +29,31 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
20
29
  const clientManagement = useNetworkActivityClientManagement();
21
30
  const hasSelectedRequest = useHasSelectedRequest();
22
31
  const overrides = useOverrides();
23
- const [filter, setFilter] = useState<FilterState>({
24
- text: '',
25
- types: new Set(['http', 'websocket', 'sse']),
26
- });
32
+ const processedRequests = useProcessedRequests();
33
+ const [filter, setFilter] = useState<FilterState>(() =>
34
+ createDefaultFilter(),
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]);
27
57
 
28
58
  useEffect(() => {
29
59
  if (!client) {
@@ -49,13 +79,18 @@ export const InspectorView = ({ client }: InspectorViewProps) => {
49
79
  <FilterBar filter={filter} onFilterChange={setFilter} />
50
80
 
51
81
  <div className="flex flex-1 overflow-hidden">
52
- {/* Request List */}
53
82
  <div
54
83
  className={`flex flex-col ${
55
84
  hasSelectedRequest ? 'w-1/2' : 'w-full'
56
85
  } border-r border-gray-700 overflow-hidden`}
57
86
  >
58
- <RequestList filter={filter} />
87
+ <NetworkTimeline
88
+ requests={filteredRequests}
89
+ selection={timelineSelection}
90
+ filteredRequestCount={visibleRequests.length}
91
+ onSelectionChange={setTimelineSelection}
92
+ />
93
+ <RequestList requests={visibleRequests} />
59
94
  </div>
60
95
 
61
96
  {hasSelectedRequest && <SidePanel />}