@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.
- package/CHANGELOG.md +60 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-hSoryVpJ.js → App-CEESZAW_.js} +7520 -937
- package/dist/devtools/assets/{App-m6xge0az.css → App-xppYUJvX.css} +246 -2
- package/dist/react-native/chunks/boot-recording.cjs +138 -14
- package/dist/react-native/chunks/boot-recording.js +138 -14
- package/dist/react-native/chunks/get-nitro-module.cjs +4 -1
- package/dist/react-native/chunks/get-nitro-module.js +4 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.cjs +20 -1
- package/dist/react-native/chunks/useNetworkActivityDevTools.require.js +20 -1
- package/dist/react-native/index.d.ts +37 -1
- package/dist/rozenite.json +1 -1
- package/dist/sdk/index.d.ts +37 -1
- package/package.json +12 -7
- package/src/react-native/agent/use-network-activity-agent-tools.ts +22 -4
- package/src/react-native/http/__tests__/http-utils.test.ts +228 -0
- package/src/react-native/http/http-utils.ts +208 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +5 -1
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +8 -2
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +337 -61
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +19 -40
- package/src/ui/components/SidePanel.tsx +42 -1
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/components/ViewToggle.tsx +44 -0
- package/src/ui/components/XmlTree.tsx +160 -0
- package/src/ui/components/__tests__/CodeBlock.test.tsx +89 -0
- package/src/ui/components/__tests__/HexView.test.tsx +41 -0
- package/src/ui/components/__tests__/MetadataCard.test.tsx +107 -0
- package/src/ui/components/__tests__/ViewToggle.test.tsx +80 -0
- package/src/ui/components/__tests__/XmlTree.test.tsx +149 -0
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/response-renderers/__tests__/binary-too-large.test.tsx +56 -0
- package/src/ui/response-renderers/__tests__/binary.test.tsx +96 -0
- package/src/ui/response-renderers/__tests__/dispatch.test.ts +124 -0
- package/src/ui/response-renderers/__tests__/html.test.tsx +101 -0
- package/src/ui/response-renderers/__tests__/image.test.tsx +73 -0
- package/src/ui/response-renderers/__tests__/json.test.tsx +95 -0
- package/src/ui/response-renderers/__tests__/svg.test.tsx +46 -0
- package/src/ui/response-renderers/__tests__/xml.test.tsx +100 -0
- package/src/ui/response-renderers/binary-too-large.tsx +36 -0
- package/src/ui/response-renderers/binary.tsx +31 -0
- package/src/ui/response-renderers/empty.tsx +14 -0
- package/src/ui/response-renderers/html.tsx +36 -0
- package/src/ui/response-renderers/image.tsx +37 -0
- package/src/ui/response-renderers/index.ts +55 -0
- package/src/ui/response-renderers/json.tsx +40 -0
- package/src/ui/response-renderers/svg.tsx +27 -0
- package/src/ui/response-renderers/text-fallback.tsx +14 -0
- package/src/ui/response-renderers/types.ts +38 -0
- package/src/ui/response-renderers/unknown.tsx +18 -0
- package/src/ui/response-renderers/xml.tsx +46 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +14 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +7 -1
- package/src/ui/state/store.ts +63 -4
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -97
- package/src/ui/tabs/__tests__/ResponseTab.test.tsx +102 -0
- package/src/ui/utils/__tests__/download.test.ts +115 -0
- package/src/ui/utils/__tests__/hex.test.ts +84 -0
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/symbolication.test.ts +207 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +161 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +43 -8
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +34 -0
- package/src/utils/getContentTypeMimeType.ts +14 -0
- package/vite.config.ts +5 -1
- package/vitest.setup.ts +31 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { CSSProperties, PointerEvent } from 'react';
|
|
3
|
+
import { X } from 'lucide-react';
|
|
4
|
+
import type { RequestId } from '../../shared/client';
|
|
5
|
+
import type { ProcessedRequest } from '../state/model';
|
|
6
|
+
import {
|
|
7
|
+
useNetworkActivityActions,
|
|
8
|
+
useSelectedRequestId,
|
|
9
|
+
} from '../state/hooks';
|
|
10
|
+
import {
|
|
11
|
+
formatTimelineOffset,
|
|
12
|
+
getTimelineBarTopOffset,
|
|
13
|
+
getTimelineModel,
|
|
14
|
+
getTimelineTrackTop,
|
|
15
|
+
isRequestActive,
|
|
16
|
+
TIMELINE_LAYOUT,
|
|
17
|
+
} from '../utils/timelineModel';
|
|
18
|
+
import type {
|
|
19
|
+
TimelineModel,
|
|
20
|
+
TimelineRangeSelection,
|
|
21
|
+
TimelineRow,
|
|
22
|
+
TimelineTick,
|
|
23
|
+
} from '../utils/timelineModel';
|
|
24
|
+
|
|
25
|
+
const REQUEST_TIMELINE_COLORS = {
|
|
26
|
+
error: 'bg-red-400',
|
|
27
|
+
primary: 'bg-gray-400',
|
|
28
|
+
active: 'bg-gray-500',
|
|
29
|
+
httpTtfb: 'bg-gray-200',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const getPrimaryBarClassName = (request: ProcessedRequest) => {
|
|
33
|
+
if (request.status === 'failed' || request.status === 'error') {
|
|
34
|
+
return REQUEST_TIMELINE_COLORS.error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return REQUEST_TIMELINE_COLORS.primary;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const getStyle = (
|
|
41
|
+
offsetPercent: number,
|
|
42
|
+
widthPercent: number,
|
|
43
|
+
): CSSProperties => ({
|
|
44
|
+
left: `${offsetPercent}%`,
|
|
45
|
+
width: `${widthPercent}%`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const GridLines = ({ ticks }: { ticks: TimelineTick[] }) => {
|
|
49
|
+
return (
|
|
50
|
+
<div className="pointer-events-none absolute inset-0">
|
|
51
|
+
{ticks.map((tick) => (
|
|
52
|
+
<div
|
|
53
|
+
key={`${tick.label}-${tick.offsetPercent}`}
|
|
54
|
+
className="absolute inset-y-0 border-l border-gray-800"
|
|
55
|
+
style={{ left: `${tick.offsetPercent}%` }}
|
|
56
|
+
/>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const getTickLabelStyle = (tick: TimelineTick): CSSProperties => {
|
|
63
|
+
if (tick.offsetPercent === 0) {
|
|
64
|
+
return {
|
|
65
|
+
left: 4,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (tick.offsetPercent === 100) {
|
|
70
|
+
return {
|
|
71
|
+
right: 4,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
left: `${tick.offsetPercent}%`,
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const TimelineTrack = ({
|
|
81
|
+
row,
|
|
82
|
+
isSelected,
|
|
83
|
+
onSelect,
|
|
84
|
+
shouldSuppressSelect,
|
|
85
|
+
}: {
|
|
86
|
+
row: TimelineRow;
|
|
87
|
+
isSelected: boolean;
|
|
88
|
+
onSelect: (requestId: RequestId) => void;
|
|
89
|
+
shouldSuppressSelect: () => boolean;
|
|
90
|
+
}) => {
|
|
91
|
+
const primaryBarClassName = row.isActive
|
|
92
|
+
? REQUEST_TIMELINE_COLORS.active
|
|
93
|
+
: getPrimaryBarClassName(row.request);
|
|
94
|
+
const isSplitHttpBar =
|
|
95
|
+
row.request.type === 'http' &&
|
|
96
|
+
row.ttfbPercent > 0 &&
|
|
97
|
+
row.receivePercent > 0;
|
|
98
|
+
const trackTop = getTimelineTrackTop(row.lane);
|
|
99
|
+
const barTop = getTimelineBarTopOffset();
|
|
100
|
+
const positionStyle = {
|
|
101
|
+
...getStyle(row.offsetPercent, row.widthPercent),
|
|
102
|
+
top: trackTop,
|
|
103
|
+
};
|
|
104
|
+
const durationLabel = row.isActive
|
|
105
|
+
? `${formatTimelineOffset(row.duration)}+`
|
|
106
|
+
: formatTimelineOffset(row.duration);
|
|
107
|
+
const label = `${row.request.method} ${row.request.name} - ${durationLabel}`;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
aria-label={label}
|
|
113
|
+
title={label}
|
|
114
|
+
data-timeline-track="true"
|
|
115
|
+
className="absolute rounded-sm text-left transition-opacity hover:opacity-80"
|
|
116
|
+
style={{
|
|
117
|
+
...positionStyle,
|
|
118
|
+
height: TIMELINE_LAYOUT.laneHitTargetHeightPx,
|
|
119
|
+
}}
|
|
120
|
+
onClick={(event) => {
|
|
121
|
+
if (shouldSuppressSelect()) {
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
event.stopPropagation();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onSelect(row.request.id);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{isSplitHttpBar ? (
|
|
131
|
+
<div
|
|
132
|
+
className={`absolute flex w-full overflow-hidden rounded-sm ${
|
|
133
|
+
isSelected
|
|
134
|
+
? 'ring-1 ring-blue-300 ring-offset-1 ring-offset-gray-950'
|
|
135
|
+
: ''
|
|
136
|
+
}`}
|
|
137
|
+
style={{
|
|
138
|
+
top: barTop,
|
|
139
|
+
height: TIMELINE_LAYOUT.laneHeightPx,
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<div
|
|
143
|
+
className={`h-full ${REQUEST_TIMELINE_COLORS.httpTtfb}`}
|
|
144
|
+
style={{ width: `${row.ttfbPercent}%` }}
|
|
145
|
+
/>
|
|
146
|
+
<div
|
|
147
|
+
className={`h-full ${REQUEST_TIMELINE_COLORS.primary}`}
|
|
148
|
+
style={{ width: `${row.receivePercent}%` }}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
) : (
|
|
152
|
+
<div
|
|
153
|
+
className={`absolute w-full rounded-sm ${primaryBarClassName} ${
|
|
154
|
+
isSelected
|
|
155
|
+
? 'ring-1 ring-blue-300 ring-offset-1 ring-offset-gray-950'
|
|
156
|
+
: ''
|
|
157
|
+
}`}
|
|
158
|
+
style={{
|
|
159
|
+
top: barTop,
|
|
160
|
+
height: TIMELINE_LAYOUT.laneHeightPx,
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
)}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
type DraftSelection = {
|
|
169
|
+
anchorPercent: number;
|
|
170
|
+
currentPercent: number;
|
|
171
|
+
startedOnTrack: boolean;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100);
|
|
175
|
+
|
|
176
|
+
const getPointerPercent = (
|
|
177
|
+
event: PointerEvent<HTMLDivElement>,
|
|
178
|
+
element: HTMLDivElement,
|
|
179
|
+
) => {
|
|
180
|
+
const rect = element.getBoundingClientRect();
|
|
181
|
+
|
|
182
|
+
if (rect.width === 0) {
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return clampPercent(((event.clientX - rect.left) / rect.width) * 100);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const getSelectionStyle = (
|
|
190
|
+
range: TimelineRangeSelection,
|
|
191
|
+
timeline: TimelineModel,
|
|
192
|
+
): CSSProperties => {
|
|
193
|
+
const startPercent = clampPercent(
|
|
194
|
+
((range.startTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
|
|
195
|
+
);
|
|
196
|
+
const endPercent = clampPercent(
|
|
197
|
+
((range.endTime - timeline.rangeStart) / timeline.rangeDuration) * 100,
|
|
198
|
+
);
|
|
199
|
+
const left = Math.min(startPercent, endPercent);
|
|
200
|
+
const width = Math.abs(endPercent - startPercent);
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
left: `${left}%`,
|
|
204
|
+
width: `${width}%`,
|
|
205
|
+
top: TIMELINE_LAYOUT.rulerHeightPx,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const getDraftSelectionStyle = (draft: DraftSelection): CSSProperties => {
|
|
210
|
+
const left = Math.min(draft.anchorPercent, draft.currentPercent);
|
|
211
|
+
const width = Math.abs(draft.currentPercent - draft.anchorPercent);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
left: `${left}%`,
|
|
215
|
+
width: `${width}%`,
|
|
216
|
+
top: TIMELINE_LAYOUT.rulerHeightPx,
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type NetworkTimelineProps = {
|
|
221
|
+
requests: ProcessedRequest[];
|
|
222
|
+
selection: TimelineRangeSelection | null;
|
|
223
|
+
filteredRequestCount: number;
|
|
224
|
+
onSelectionChange: (selection: TimelineRangeSelection | null) => void;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export const NetworkTimeline = ({
|
|
228
|
+
requests,
|
|
229
|
+
selection,
|
|
230
|
+
filteredRequestCount,
|
|
231
|
+
onSelectionChange,
|
|
232
|
+
}: NetworkTimelineProps) => {
|
|
233
|
+
const actions = useNetworkActivityActions();
|
|
234
|
+
const selectedRequestId = useSelectedRequestId();
|
|
235
|
+
const [now, setNow] = useState(() => Date.now());
|
|
236
|
+
const [draftSelection, setDraftSelection] = useState<DraftSelection | null>(
|
|
237
|
+
null,
|
|
238
|
+
);
|
|
239
|
+
const chartRef = useRef<HTMLDivElement | null>(null);
|
|
240
|
+
const suppressTrackClickRef = useRef(false);
|
|
241
|
+
|
|
242
|
+
const hasActiveRequests = requests.some(isRequestActive);
|
|
243
|
+
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!hasActiveRequests) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const interval = window.setInterval(() => {
|
|
250
|
+
setNow(Date.now());
|
|
251
|
+
}, TIMELINE_LAYOUT.liveRefreshMs);
|
|
252
|
+
|
|
253
|
+
return () => window.clearInterval(interval);
|
|
254
|
+
}, [hasActiveRequests]);
|
|
255
|
+
|
|
256
|
+
const timeline = useMemo(() => {
|
|
257
|
+
return getTimelineModel(requests, now);
|
|
258
|
+
}, [requests, now]);
|
|
259
|
+
|
|
260
|
+
const onRequestSelect = (requestId: RequestId) => {
|
|
261
|
+
actions.setSelectedRequest(requestId);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
|
265
|
+
if (event.button !== 0 || requests.length === 0) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const chartElement = chartRef.current;
|
|
270
|
+
|
|
271
|
+
if (!chartElement) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const percent = getPointerPercent(event, chartElement);
|
|
276
|
+
const target = event.target;
|
|
277
|
+
const startedOnTrack =
|
|
278
|
+
target instanceof Element &&
|
|
279
|
+
target.closest('[data-timeline-track="true"]') !== null;
|
|
280
|
+
|
|
281
|
+
setDraftSelection({
|
|
282
|
+
anchorPercent: percent,
|
|
283
|
+
currentPercent: percent,
|
|
284
|
+
startedOnTrack,
|
|
285
|
+
});
|
|
286
|
+
chartElement.setPointerCapture(event.pointerId);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
|
290
|
+
if (!draftSelection) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const chartElement = chartRef.current;
|
|
295
|
+
|
|
296
|
+
if (!chartElement) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
const percent = getPointerPercent(event, chartElement);
|
|
302
|
+
|
|
303
|
+
setDraftSelection((current) =>
|
|
304
|
+
current ? { ...current, currentPercent: percent } : current,
|
|
305
|
+
);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
|
309
|
+
if (!draftSelection) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const chartElement = chartRef.current;
|
|
314
|
+
const currentPercent = chartElement
|
|
315
|
+
? getPointerPercent(event, chartElement)
|
|
316
|
+
: draftSelection.currentPercent;
|
|
317
|
+
const distance = Math.abs(currentPercent - draftSelection.anchorPercent);
|
|
318
|
+
|
|
319
|
+
if (distance > 1) {
|
|
320
|
+
const startOffset =
|
|
321
|
+
(Math.min(draftSelection.anchorPercent, currentPercent) / 100) *
|
|
322
|
+
timeline.rangeDuration;
|
|
323
|
+
const endOffset =
|
|
324
|
+
(Math.max(draftSelection.anchorPercent, currentPercent) / 100) *
|
|
325
|
+
timeline.rangeDuration;
|
|
326
|
+
|
|
327
|
+
onSelectionChange({
|
|
328
|
+
startTime: timeline.rangeStart + startOffset,
|
|
329
|
+
endTime: timeline.rangeStart + endOffset,
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
suppressTrackClickRef.current = true;
|
|
333
|
+
window.setTimeout(() => {
|
|
334
|
+
suppressTrackClickRef.current = false;
|
|
335
|
+
}, 0);
|
|
336
|
+
} else if (!draftSelection.startedOnTrack) {
|
|
337
|
+
onSelectionChange(null);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
setDraftSelection(null);
|
|
341
|
+
|
|
342
|
+
if (chartElement?.hasPointerCapture(event.pointerId)) {
|
|
343
|
+
chartElement.releasePointerCapture(event.pointerId);
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return (
|
|
348
|
+
<div className="border-b border-gray-700 bg-gray-900 p-1.5">
|
|
349
|
+
<div
|
|
350
|
+
ref={chartRef}
|
|
351
|
+
className="relative overflow-hidden border border-gray-800 bg-gray-950"
|
|
352
|
+
style={{ height: timeline.chartHeight }}
|
|
353
|
+
onPointerDown={onPointerDown}
|
|
354
|
+
onPointerMove={onPointerMove}
|
|
355
|
+
onPointerUp={onPointerUp}
|
|
356
|
+
>
|
|
357
|
+
<GridLines ticks={timeline.ticks} />
|
|
358
|
+
|
|
359
|
+
<div
|
|
360
|
+
className="pointer-events-none absolute inset-x-0 border-b border-gray-800"
|
|
361
|
+
style={{ top: TIMELINE_LAYOUT.rulerHeightPx }}
|
|
362
|
+
/>
|
|
363
|
+
|
|
364
|
+
{timeline.ticks.map((tick) => (
|
|
365
|
+
<div
|
|
366
|
+
key={`${tick.label}-${tick.offsetPercent}`}
|
|
367
|
+
className="absolute top-1 whitespace-nowrap tabular-nums text-xs text-gray-200"
|
|
368
|
+
style={getTickLabelStyle(tick)}
|
|
369
|
+
>
|
|
370
|
+
{tick.label}
|
|
371
|
+
</div>
|
|
372
|
+
))}
|
|
373
|
+
|
|
374
|
+
{selection && (
|
|
375
|
+
<div
|
|
376
|
+
className="pointer-events-none absolute bottom-0 border-x border-blue-300/70 bg-blue-400/10"
|
|
377
|
+
style={getSelectionStyle(selection, timeline)}
|
|
378
|
+
/>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{draftSelection && (
|
|
382
|
+
<div
|
|
383
|
+
className="pointer-events-none absolute bottom-0 border-x border-blue-300/70 bg-blue-400/15"
|
|
384
|
+
style={getDraftSelectionStyle(draftSelection)}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{timeline.rows.map((row) => (
|
|
389
|
+
<TimelineTrack
|
|
390
|
+
key={row.request.id}
|
|
391
|
+
row={row}
|
|
392
|
+
isSelected={selectedRequestId === row.request.id}
|
|
393
|
+
onSelect={onRequestSelect}
|
|
394
|
+
shouldSuppressSelect={() => suppressTrackClickRef.current}
|
|
395
|
+
/>
|
|
396
|
+
))}
|
|
397
|
+
|
|
398
|
+
{selection && (
|
|
399
|
+
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded border border-gray-700 bg-gray-900/95 px-1.5 py-0.5 text-xs text-gray-400">
|
|
400
|
+
<span>{filteredRequestCount} in range</span>
|
|
401
|
+
<button
|
|
402
|
+
type="button"
|
|
403
|
+
title="Clear timeline selection"
|
|
404
|
+
aria-label="Clear timeline selection"
|
|
405
|
+
className="rounded p-0.5 text-gray-400 hover:bg-gray-800 hover:text-gray-100"
|
|
406
|
+
onClick={() => onSelectionChange(null)}
|
|
407
|
+
>
|
|
408
|
+
<X className="h-3 w-3" />
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
412
|
+
|
|
413
|
+
{timeline.hiddenRequestCount > 0 && (
|
|
414
|
+
<div className="absolute bottom-1 left-1 rounded border border-gray-700 bg-gray-900/95 px-1.5 py-0.5 text-xs text-gray-400">
|
|
415
|
+
Showing latest {timeline.rows.length} of{' '}
|
|
416
|
+
{timeline.totalRequestCount}
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
};
|
|
@@ -8,30 +8,35 @@ import {
|
|
|
8
8
|
SortingState,
|
|
9
9
|
useReactTable,
|
|
10
10
|
} from '@tanstack/react-table';
|
|
11
|
-
import { ProcessedRequest } from '../state/model';
|
|
12
|
-
import {
|
|
11
|
+
import type { ProcessedRequest } from '../state/model';
|
|
12
|
+
import type {
|
|
13
|
+
NetworkEventSource,
|
|
14
|
+
RequestId,
|
|
15
|
+
RequestOverride,
|
|
16
|
+
} from '../../shared/client';
|
|
13
17
|
import {
|
|
14
18
|
useNetworkActivityActions,
|
|
15
19
|
useOverrides,
|
|
16
|
-
useProcessedRequests,
|
|
17
20
|
useSelectedRequestId,
|
|
18
21
|
useClientUISettings,
|
|
19
22
|
} from '../state/hooks';
|
|
20
23
|
import { getStatusColor } from '../utils/getStatusColor';
|
|
21
|
-
import { FilterState } from './FilterBar';
|
|
22
24
|
import { isNumber } from '../../utils/typeChecks';
|
|
23
|
-
import type { NetworkEventSource } from '../../shared/client';
|
|
24
25
|
|
|
25
26
|
type NetworkRequest = {
|
|
26
27
|
id: RequestId;
|
|
27
28
|
name: string;
|
|
28
29
|
status: string | number;
|
|
29
|
-
|
|
30
|
+
statusCode?: number;
|
|
31
|
+
method: ProcessedRequest['method'];
|
|
30
32
|
domain: string;
|
|
31
33
|
path: string;
|
|
34
|
+
contentType?: string;
|
|
32
35
|
size: string;
|
|
36
|
+
sizeBytes: number | null;
|
|
33
37
|
time: string;
|
|
34
|
-
|
|
38
|
+
durationMs: number;
|
|
39
|
+
type: ProcessedRequest['type'];
|
|
35
40
|
source?: NetworkEventSource;
|
|
36
41
|
startTime: string;
|
|
37
42
|
hasOverride: boolean;
|
|
@@ -105,7 +110,6 @@ const sortSize: SortingFn<NetworkRequest> = (rowA, rowB, columnId) => {
|
|
|
105
110
|
const a = rowA.getValue(columnId) as string;
|
|
106
111
|
const b = rowB.getValue(columnId) as string;
|
|
107
112
|
|
|
108
|
-
// Extract numeric values from formatted strings like "1.2 kB", "500 B", etc.
|
|
109
113
|
const getNumericValue = (str: string) => {
|
|
110
114
|
const match = str.match(/^([\d.]+)\s*([KMGT]?B)$/);
|
|
111
115
|
if (!match) return 0;
|
|
@@ -127,7 +131,6 @@ const sortTime: SortingFn<NetworkRequest> = (rowA, rowB, columnId) => {
|
|
|
127
131
|
const a = rowA.getValue(columnId) as string;
|
|
128
132
|
const b = rowB.getValue(columnId) as string;
|
|
129
133
|
|
|
130
|
-
// Extract numeric values from formatted strings like "150 ms", "1.2 s", etc.
|
|
131
134
|
const getNumericValue = (str: string) => {
|
|
132
135
|
const match = str.match(/^([\d.]+)\s*(ms|s)$/);
|
|
133
136
|
if (!match) return 0;
|
|
@@ -161,15 +164,19 @@ const processNetworkRequests = (
|
|
|
161
164
|
id: request.id,
|
|
162
165
|
name: generateName(request.name, showEntirePathAsName),
|
|
163
166
|
status: statusDisplay,
|
|
167
|
+
statusCode: request.httpStatus || undefined,
|
|
164
168
|
method: request.method,
|
|
165
169
|
domain,
|
|
166
170
|
path,
|
|
171
|
+
contentType: request.contentType,
|
|
167
172
|
size: isNumber(request.size) ? formatSize(request.size) : '—',
|
|
173
|
+
sizeBytes: isNumber(request.size) ? request.size : null,
|
|
168
174
|
time: formatDuration(duration),
|
|
175
|
+
durationMs: duration,
|
|
169
176
|
type: request.type,
|
|
170
177
|
source: request.source,
|
|
171
178
|
startTime: formatStartTime(request.timestamp),
|
|
172
|
-
hasOverride
|
|
179
|
+
hasOverride,
|
|
173
180
|
};
|
|
174
181
|
});
|
|
175
182
|
};
|
|
@@ -245,43 +252,16 @@ const columns = [
|
|
|
245
252
|
];
|
|
246
253
|
|
|
247
254
|
export type RequestListProps = {
|
|
248
|
-
|
|
255
|
+
requests: ProcessedRequest[];
|
|
249
256
|
};
|
|
250
257
|
|
|
251
|
-
export const RequestList = ({
|
|
258
|
+
export const RequestList = ({ requests: filteredRequests }: RequestListProps) => {
|
|
252
259
|
const actions = useNetworkActivityActions();
|
|
253
|
-
const processedRequests = useProcessedRequests();
|
|
254
260
|
const selectedRequestId = useSelectedRequestId();
|
|
255
261
|
const [sorting, setSorting] = useState<SortingState>([]);
|
|
256
262
|
const overrides = useOverrides();
|
|
257
263
|
const clientUISettings = useClientUISettings();
|
|
258
264
|
|
|
259
|
-
// Filter requests based on current filter state
|
|
260
|
-
const filteredRequests = useMemo(() => {
|
|
261
|
-
return processedRequests.filter((request) => {
|
|
262
|
-
// Type filter
|
|
263
|
-
if (!filter.types.has(request.type)) {
|
|
264
|
-
return false;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Text filter
|
|
268
|
-
if (filter.text) {
|
|
269
|
-
const searchText = filter.text.toLowerCase();
|
|
270
|
-
const searchableFields = [
|
|
271
|
-
request.name,
|
|
272
|
-
request.method,
|
|
273
|
-
request.status.toString(),
|
|
274
|
-
]
|
|
275
|
-
.join(' ')
|
|
276
|
-
.toLowerCase();
|
|
277
|
-
|
|
278
|
-
return searchableFields.includes(searchText);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return true;
|
|
282
|
-
});
|
|
283
|
-
}, [processedRequests, filter]);
|
|
284
|
-
|
|
285
265
|
const requests = useMemo(() => {
|
|
286
266
|
return processNetworkRequests(
|
|
287
267
|
filteredRequests,
|
|
@@ -369,7 +349,6 @@ export const RequestList = ({ filter }: RequestListProps) => {
|
|
|
369
349
|
);
|
|
370
350
|
};
|
|
371
351
|
|
|
372
|
-
// Export helper functions for use in other components
|
|
373
352
|
export {
|
|
374
353
|
formatSize,
|
|
375
354
|
formatDuration,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
1
2
|
import { Badge } from './Badge';
|
|
2
3
|
import { Button } from './Button';
|
|
3
4
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
|
|
@@ -6,6 +7,7 @@ import { RequestTab } from '../tabs/RequestTab';
|
|
|
6
7
|
import { ResponseTab } from '../tabs/ResponseTab';
|
|
7
8
|
import { CookiesTab } from '../tabs/CookiesTab';
|
|
8
9
|
import { TimingTab } from '../tabs/TimingTab';
|
|
10
|
+
import { InitiatorTab } from '../tabs/InitiatorTab';
|
|
9
11
|
import { X } from 'lucide-react';
|
|
10
12
|
import {
|
|
11
13
|
useNetworkActivityActions,
|
|
@@ -17,6 +19,7 @@ import { NetworkEntry as OldNetworkEntry } from '../types';
|
|
|
17
19
|
import { getStatusColor } from '../utils/getStatusColor';
|
|
18
20
|
import { MessagesTab } from '../tabs/MessagesTab';
|
|
19
21
|
import { SSEMessagesTab } from '../tabs/SSEMessagesTab';
|
|
22
|
+
import type { ResponseView } from '../response-renderers';
|
|
20
23
|
|
|
21
24
|
const getTypeColor = (type: string) => {
|
|
22
25
|
const colors: Record<string, string> = {
|
|
@@ -87,6 +90,14 @@ export const SidePanel = () => {
|
|
|
87
90
|
const selectedRequest = useSelectedRequest();
|
|
88
91
|
const client = useNetworkActivityStore((state) => state._client);
|
|
89
92
|
const overrides = useOverrides();
|
|
93
|
+
// Sticky Preview/Raw preference. Lives here, not in ResponseTab,
|
|
94
|
+
// because the `<Tabs key={selectedRequest.id}>` below intentionally
|
|
95
|
+
// remounts the Tabs subtree on every request switch (so the active
|
|
96
|
+
// inner tab resets). SidePanel itself stays mounted across request
|
|
97
|
+
// switches, so the preference survives — flipping to Raw on one
|
|
98
|
+
// response keeps Raw selected for every subsequent response whose
|
|
99
|
+
// renderer supports it. Resets when the panel is closed.
|
|
100
|
+
const [preferredView, setPreferredView] = useState<ResponseView>('preview');
|
|
90
101
|
|
|
91
102
|
const onClose = (): void => {
|
|
92
103
|
actions.setSelectedRequest(null);
|
|
@@ -166,6 +177,12 @@ export const SidePanel = () => {
|
|
|
166
177
|
>
|
|
167
178
|
Cookies
|
|
168
179
|
</TabsTrigger>
|
|
180
|
+
<TabsTrigger
|
|
181
|
+
value="initiator"
|
|
182
|
+
className="data-[state=active]:bg-gray-700"
|
|
183
|
+
>
|
|
184
|
+
Initiator
|
|
185
|
+
</TabsTrigger>
|
|
169
186
|
<TabsTrigger
|
|
170
187
|
value="timing"
|
|
171
188
|
className="data-[state=active]:bg-gray-700"
|
|
@@ -197,6 +214,12 @@ export const SidePanel = () => {
|
|
|
197
214
|
>
|
|
198
215
|
Messages
|
|
199
216
|
</TabsTrigger>
|
|
217
|
+
<TabsTrigger
|
|
218
|
+
value="initiator"
|
|
219
|
+
className="data-[state=active]:bg-gray-700"
|
|
220
|
+
>
|
|
221
|
+
Initiator
|
|
222
|
+
</TabsTrigger>
|
|
200
223
|
</>
|
|
201
224
|
);
|
|
202
225
|
}
|
|
@@ -229,6 +252,8 @@ export const SidePanel = () => {
|
|
|
229
252
|
<ResponseTab
|
|
230
253
|
selectedRequest={httpDetails}
|
|
231
254
|
supportsOverrides={supportsOverrides}
|
|
255
|
+
preferredView={preferredView}
|
|
256
|
+
onPreferredViewChange={setPreferredView}
|
|
232
257
|
onRequestResponseBody={(requestId) => {
|
|
233
258
|
if (client) {
|
|
234
259
|
client.send('get-response-body', {
|
|
@@ -243,6 +268,10 @@ export const SidePanel = () => {
|
|
|
243
268
|
<CookiesTab selectedRequest={httpDetails} />
|
|
244
269
|
</TabsContent>
|
|
245
270
|
|
|
271
|
+
<TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
|
|
272
|
+
<InitiatorTab selectedRequest={httpDetails} />
|
|
273
|
+
</TabsContent>
|
|
274
|
+
|
|
246
275
|
<TabsContent value="timing" className="flex-1 m-0 overflow-hidden">
|
|
247
276
|
<TimingTab selectedRequest={httpDetails} />
|
|
248
277
|
</TabsContent>
|
|
@@ -275,6 +304,10 @@ export const SidePanel = () => {
|
|
|
275
304
|
<SSEMessagesTab selectedRequest={sseDetails} />
|
|
276
305
|
</TabsContent>
|
|
277
306
|
|
|
307
|
+
<TabsContent value="initiator" className="flex-1 m-0 overflow-hidden">
|
|
308
|
+
<InitiatorTab selectedRequest={sseDetails} />
|
|
309
|
+
</TabsContent>
|
|
310
|
+
|
|
278
311
|
<TabsContent value="cookies" className="flex-1 m-0 overflow-hidden">
|
|
279
312
|
<CookiesTab selectedRequest={sseDetails} />
|
|
280
313
|
</TabsContent>
|
|
@@ -324,7 +357,15 @@ export const SidePanel = () => {
|
|
|
324
357
|
}
|
|
325
358
|
className="h-full flex flex-col"
|
|
326
359
|
>
|
|
327
|
-
<TabsList
|
|
360
|
+
<TabsList
|
|
361
|
+
className={`grid w-full ${
|
|
362
|
+
selectedRequest.type === 'http'
|
|
363
|
+
? 'grid-cols-6'
|
|
364
|
+
: selectedRequest.type === 'sse'
|
|
365
|
+
? 'grid-cols-4'
|
|
366
|
+
: 'grid-cols-1'
|
|
367
|
+
} bg-gray-800 rounded-none border-b border-gray-700`}
|
|
368
|
+
>
|
|
328
369
|
{getTabsListTriggers()}
|
|
329
370
|
</TabsList>
|
|
330
371
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Button } from './Button';
|
|
2
|
-
import { Circle, Square, Trash2 } from 'lucide-react';
|
|
2
|
+
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
|
3
3
|
import { useIsRecording, useNetworkActivityActions } from '../state/hooks';
|
|
4
|
+
import { useNetworkActivitySessionExport } from '../hooks/useNetworkActivitySessionExport';
|
|
4
5
|
|
|
5
6
|
export const Toolbar = () => {
|
|
6
7
|
const actions = useNetworkActivityActions();
|
|
7
8
|
const isRecording = useIsRecording();
|
|
9
|
+
const { canExportSession, exportSession } = useNetworkActivitySessionExport();
|
|
8
10
|
|
|
9
11
|
const onToggleRecording = (): void => {
|
|
10
12
|
actions.setRecording(!isRecording);
|
|
@@ -41,6 +43,16 @@ export const Toolbar = () => {
|
|
|
41
43
|
>
|
|
42
44
|
<Trash2 className="h-4 w-4" />
|
|
43
45
|
</Button>
|
|
46
|
+
<Button
|
|
47
|
+
variant="ghost"
|
|
48
|
+
size="sm"
|
|
49
|
+
onClick={exportSession}
|
|
50
|
+
disabled={!canExportSession}
|
|
51
|
+
className="ml-auto h-8 w-8 p-0 text-gray-400 hover:text-blue-400"
|
|
52
|
+
title="Export session"
|
|
53
|
+
>
|
|
54
|
+
<Download className="h-4 w-4" />
|
|
55
|
+
</Button>
|
|
44
56
|
</div>
|
|
45
57
|
);
|
|
46
58
|
};
|