@rozenite/network-activity-plugin 1.8.1 → 1.10.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 +56 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-m6xge0az.css → App-CUXU0mup.css} +152 -2
- package/dist/devtools/assets/{App-B3xlUjs6.js → App-DsimzJvx.js} +6833 -966
- package/dist/react-native/chunks/boot-recording.cjs +156 -28
- package/dist/react-native/chunks/boot-recording.js +156 -28
- package/dist/react-native/chunks/get-nitro-module.cjs +12 -0
- package/dist/react-native/chunks/get-nitro-module.js +13 -0
- 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 +39 -3
- 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 +209 -25
- package/src/react-native/network-inspector.ts +2 -2
- package/src/react-native/nitro-fetch/get-nitro-module.ts +13 -0
- package/src/react-native/nitro-fetch/nitro-network-inspector.ts +10 -11
- package/src/shared/http-events.ts +40 -1
- package/src/ui/components/CodeBlock.tsx +45 -1
- package/src/ui/components/FilterBar.tsx +366 -58
- package/src/ui/components/HexView.tsx +54 -0
- package/src/ui/components/MetadataCard.tsx +95 -0
- package/src/ui/components/RequestList.tsx +192 -34
- package/src/ui/components/SidePanel.tsx +42 -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/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/derived.ts +12 -0
- package/src/ui/state/model.ts +6 -1
- package/src/ui/state/store.ts +39 -2
- package/src/ui/tabs/InitiatorTab.tsx +230 -0
- package/src/ui/tabs/ResponseTab.tsx +80 -96
- 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__/symbolication.test.ts +207 -0
- package/src/ui/utils/download.ts +154 -0
- package/src/ui/utils/hex.ts +59 -0
- package/src/ui/utils/initiator.ts +136 -0
- package/src/ui/utils/symbolication.ts +248 -0
- package/src/ui/views/InspectorView.tsx +8 -5
- package/src/utils/__tests__/getContentTypeMimeType.test.ts +65 -0
- package/src/utils/getContentTypeMimeType.ts +28 -0
- package/vite.config.ts +9 -1
- package/vitest.setup.ts +31 -0
|
@@ -1,16 +1,42 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
autoUpdate,
|
|
4
|
+
flip,
|
|
5
|
+
FloatingPortal,
|
|
6
|
+
offset,
|
|
7
|
+
shift,
|
|
8
|
+
size,
|
|
9
|
+
useClick,
|
|
10
|
+
useDismiss,
|
|
11
|
+
useFloating,
|
|
12
|
+
useInteractions,
|
|
13
|
+
useRole,
|
|
14
|
+
} from '@floating-ui/react';
|
|
1
15
|
import { Input } from './Input';
|
|
2
16
|
import { Button } from './Button';
|
|
3
|
-
import { X, Filter, ChevronDown } from 'lucide-react';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
import { X, Filter, ChevronDown, Check } from 'lucide-react';
|
|
18
|
+
import type { HttpMethod, NetworkEventSource } from '../../shared/client';
|
|
19
|
+
|
|
20
|
+
export type RequestTypeFilter = 'http' | 'websocket' | 'sse';
|
|
21
|
+
export type AdvancedFilterState = {
|
|
22
|
+
methods: Set<HttpMethod>;
|
|
23
|
+
sources: Set<NetworkEventSource>;
|
|
24
|
+
status: string;
|
|
25
|
+
domain: string;
|
|
26
|
+
contentType: string;
|
|
27
|
+
failedOnly: boolean;
|
|
28
|
+
inFlightOnly: boolean;
|
|
29
|
+
overriddenOnly: boolean;
|
|
30
|
+
minSize: string;
|
|
31
|
+
maxSize: string;
|
|
32
|
+
minDuration: string;
|
|
33
|
+
maxDuration: string;
|
|
34
|
+
};
|
|
10
35
|
|
|
11
36
|
export type FilterState = {
|
|
12
37
|
text: string;
|
|
13
|
-
types: Set<
|
|
38
|
+
types: Set<RequestTypeFilter>;
|
|
39
|
+
advanced: AdvancedFilterState;
|
|
14
40
|
};
|
|
15
41
|
|
|
16
42
|
type FilterBarProps = {
|
|
@@ -18,12 +44,178 @@ type FilterBarProps = {
|
|
|
18
44
|
onFilterChange: (filter: FilterState) => void;
|
|
19
45
|
};
|
|
20
46
|
|
|
47
|
+
const REQUEST_TYPES: RequestTypeFilter[] = ['http', 'sse', 'websocket'];
|
|
48
|
+
const HTTP_METHODS: HttpMethod[] = [
|
|
49
|
+
'GET',
|
|
50
|
+
'POST',
|
|
51
|
+
'PUT',
|
|
52
|
+
'PATCH',
|
|
53
|
+
'DELETE',
|
|
54
|
+
'HEAD',
|
|
55
|
+
];
|
|
56
|
+
const SOURCES: NetworkEventSource[] = ['builtin', 'nitro'];
|
|
57
|
+
|
|
58
|
+
export const createDefaultFilter = (): FilterState => ({
|
|
59
|
+
text: '',
|
|
60
|
+
types: new Set(),
|
|
61
|
+
advanced: {
|
|
62
|
+
methods: new Set(),
|
|
63
|
+
sources: new Set(),
|
|
64
|
+
status: '',
|
|
65
|
+
domain: '',
|
|
66
|
+
contentType: '',
|
|
67
|
+
failedOnly: false,
|
|
68
|
+
inFlightOnly: false,
|
|
69
|
+
overriddenOnly: false,
|
|
70
|
+
minSize: '',
|
|
71
|
+
maxSize: '',
|
|
72
|
+
minDuration: '',
|
|
73
|
+
maxDuration: '',
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const getTypeLabel = (type: RequestTypeFilter) => {
|
|
78
|
+
switch (type) {
|
|
79
|
+
case 'http':
|
|
80
|
+
return 'XHR';
|
|
81
|
+
case 'websocket':
|
|
82
|
+
return 'WS';
|
|
83
|
+
case 'sse':
|
|
84
|
+
return 'SSE';
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const getSourceLabel = (source: NetworkEventSource) => {
|
|
89
|
+
switch (source) {
|
|
90
|
+
case 'builtin':
|
|
91
|
+
return 'Built-in';
|
|
92
|
+
case 'nitro':
|
|
93
|
+
return 'Nitro';
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const getAdvancedFilterCount = (advanced: AdvancedFilterState) => {
|
|
98
|
+
return [
|
|
99
|
+
advanced.methods.size > 0,
|
|
100
|
+
advanced.sources.size > 0,
|
|
101
|
+
advanced.status.trim() !== '',
|
|
102
|
+
advanced.domain.trim() !== '',
|
|
103
|
+
advanced.contentType.trim() !== '',
|
|
104
|
+
advanced.failedOnly,
|
|
105
|
+
advanced.inFlightOnly,
|
|
106
|
+
advanced.overriddenOnly,
|
|
107
|
+
advanced.minSize.trim() !== '',
|
|
108
|
+
advanced.maxSize.trim() !== '',
|
|
109
|
+
advanced.minDuration.trim() !== '',
|
|
110
|
+
advanced.maxDuration.trim() !== '',
|
|
111
|
+
].filter(Boolean).length;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const getActiveFilterCount = (filter: FilterState) => {
|
|
115
|
+
const typeFilterCount = filter.types.size > 0 ? 1 : 0;
|
|
116
|
+
|
|
117
|
+
return typeFilterCount + getAdvancedFilterCount(filter.advanced);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const FilterField = ({
|
|
121
|
+
label,
|
|
122
|
+
value,
|
|
123
|
+
placeholder,
|
|
124
|
+
onChange,
|
|
125
|
+
}: {
|
|
126
|
+
label: string;
|
|
127
|
+
value: string;
|
|
128
|
+
placeholder: string;
|
|
129
|
+
onChange: (value: string) => void;
|
|
130
|
+
}) => (
|
|
131
|
+
<label className="block space-y-1 px-2 py-1">
|
|
132
|
+
<span className="text-xs text-gray-400">{label}</span>
|
|
133
|
+
<Input
|
|
134
|
+
value={value}
|
|
135
|
+
placeholder={placeholder}
|
|
136
|
+
onChange={(event) => onChange(event.target.value)}
|
|
137
|
+
onClick={(event) => event.stopPropagation()}
|
|
138
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
139
|
+
className="h-7 bg-gray-900 border-gray-700 text-xs text-gray-100 placeholder:text-gray-500"
|
|
140
|
+
/>
|
|
141
|
+
</label>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const FilterPanelLabel = ({ children }: { children: string }) => (
|
|
145
|
+
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">
|
|
146
|
+
{children}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const FilterPanelSeparator = () => (
|
|
151
|
+
<div className="-mx-1 my-1 h-px bg-gray-700" />
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const FilterCheckbox = ({
|
|
155
|
+
checked,
|
|
156
|
+
onCheckedChange,
|
|
157
|
+
children,
|
|
158
|
+
}: {
|
|
159
|
+
checked: boolean;
|
|
160
|
+
onCheckedChange: (checked: boolean) => void;
|
|
161
|
+
children: string;
|
|
162
|
+
}) => (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
role="checkbox"
|
|
166
|
+
aria-checked={checked}
|
|
167
|
+
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-left text-sm outline-none transition-colors hover:bg-gray-700 focus:bg-gray-700 focus:text-gray-100"
|
|
168
|
+
onClick={() => onCheckedChange(!checked)}
|
|
169
|
+
>
|
|
170
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
171
|
+
{checked && <Check className="h-4 w-4" />}
|
|
172
|
+
</span>
|
|
173
|
+
{children}
|
|
174
|
+
</button>
|
|
175
|
+
);
|
|
176
|
+
|
|
21
177
|
export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
|
|
178
|
+
const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false);
|
|
179
|
+
const { refs, floatingStyles, context } = useFloating({
|
|
180
|
+
open: isFilterPanelOpen,
|
|
181
|
+
onOpenChange: setIsFilterPanelOpen,
|
|
182
|
+
whileElementsMounted: autoUpdate,
|
|
183
|
+
middleware: [
|
|
184
|
+
offset(5),
|
|
185
|
+
flip({ padding: 8 }),
|
|
186
|
+
shift({ padding: 8 }),
|
|
187
|
+
size({
|
|
188
|
+
padding: 8,
|
|
189
|
+
apply({ availableHeight, elements }) {
|
|
190
|
+
elements.floating.style.maxHeight = `${availableHeight}px`;
|
|
191
|
+
},
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
const click = useClick(context);
|
|
196
|
+
const dismiss = useDismiss(context);
|
|
197
|
+
const role = useRole(context);
|
|
198
|
+
const { getReferenceProps, getFloatingProps } = useInteractions([
|
|
199
|
+
click,
|
|
200
|
+
dismiss,
|
|
201
|
+
role,
|
|
202
|
+
]);
|
|
203
|
+
|
|
22
204
|
const handleTextChange = (text: string) => {
|
|
23
205
|
onFilterChange({ ...filter, text });
|
|
24
206
|
};
|
|
25
207
|
|
|
26
|
-
const
|
|
208
|
+
const updateAdvancedFilter = (patch: Partial<AdvancedFilterState>) => {
|
|
209
|
+
onFilterChange({
|
|
210
|
+
...filter,
|
|
211
|
+
advanced: {
|
|
212
|
+
...filter.advanced,
|
|
213
|
+
...patch,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const toggleType = (type: RequestTypeFilter) => {
|
|
27
219
|
const newTypes = new Set(filter.types);
|
|
28
220
|
if (newTypes.has(type)) {
|
|
29
221
|
newTypes.delete(type);
|
|
@@ -33,30 +225,35 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
|
|
|
33
225
|
onFilterChange({ ...filter, types: newTypes });
|
|
34
226
|
};
|
|
35
227
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
228
|
+
const toggleMethod = (method: HttpMethod) => {
|
|
229
|
+
const methods = new Set(filter.advanced.methods);
|
|
230
|
+
if (methods.has(method)) {
|
|
231
|
+
methods.delete(method);
|
|
232
|
+
} else {
|
|
233
|
+
methods.add(method);
|
|
234
|
+
}
|
|
235
|
+
updateAdvancedFilter({ methods });
|
|
41
236
|
};
|
|
42
237
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return 'XHR';
|
|
50
|
-
case 'websocket':
|
|
51
|
-
return 'WS';
|
|
52
|
-
case 'sse':
|
|
53
|
-
return 'SSE';
|
|
238
|
+
const toggleSource = (source: NetworkEventSource) => {
|
|
239
|
+
const sources = new Set(filter.advanced.sources);
|
|
240
|
+
if (sources.has(source)) {
|
|
241
|
+
sources.delete(source);
|
|
242
|
+
} else {
|
|
243
|
+
sources.add(source);
|
|
54
244
|
}
|
|
245
|
+
updateAdvancedFilter({ sources });
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const clearFilters = () => {
|
|
249
|
+
onFilterChange(createDefaultFilter());
|
|
55
250
|
};
|
|
56
251
|
|
|
252
|
+
const activeFilterCount = getActiveFilterCount(filter);
|
|
253
|
+
const hasActiveFilters = filter.text !== '' || activeFilterCount > 0;
|
|
254
|
+
|
|
57
255
|
return (
|
|
58
256
|
<div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
|
|
59
|
-
{/* Text Filter */}
|
|
60
257
|
<div className="flex-1">
|
|
61
258
|
<Input
|
|
62
259
|
placeholder="Filter requests..."
|
|
@@ -66,42 +263,153 @@ export const FilterBar = ({ filter, onFilterChange }: FilterBarProps) => {
|
|
|
66
263
|
/>
|
|
67
264
|
</div>
|
|
68
265
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
266
|
+
<Button
|
|
267
|
+
ref={refs.setReference}
|
|
268
|
+
variant="ghost"
|
|
269
|
+
size="sm"
|
|
270
|
+
className={`h-8 px-3 text-xs transition-all ${
|
|
271
|
+
activeFilterCount > 0
|
|
272
|
+
? 'bg-blue-600/20 border border-blue-500/50 text-blue-300 hover:bg-blue-600/30'
|
|
273
|
+
: 'text-gray-300 hover:text-gray-100 hover:bg-gray-700'
|
|
274
|
+
}`}
|
|
275
|
+
{...getReferenceProps()}
|
|
276
|
+
>
|
|
277
|
+
<Filter className="h-3 w-3 mr-1" />
|
|
278
|
+
Filters
|
|
279
|
+
{activeFilterCount > 0 && (
|
|
280
|
+
<span className="rounded bg-blue-500/30 px-1 text-[10px] text-blue-100">
|
|
281
|
+
{activeFilterCount}
|
|
282
|
+
</span>
|
|
283
|
+
)}
|
|
284
|
+
<ChevronDown className="h-3 w-3 ml-1" />
|
|
285
|
+
</Button>
|
|
286
|
+
|
|
287
|
+
{isFilterPanelOpen && (
|
|
288
|
+
<FloatingPortal>
|
|
289
|
+
<div
|
|
290
|
+
ref={refs.setFloating}
|
|
291
|
+
className="z-50 w-80 space-y-1 overflow-y-auto overscroll-contain rounded-md border border-gray-600 bg-gray-800 p-1 text-gray-100 shadow-lg"
|
|
292
|
+
style={floatingStyles}
|
|
293
|
+
{...getFloatingProps()}
|
|
80
294
|
>
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
295
|
+
<FilterPanelLabel>Request Type</FilterPanelLabel>
|
|
296
|
+
{REQUEST_TYPES.map((type) => (
|
|
297
|
+
<FilterCheckbox
|
|
298
|
+
key={type}
|
|
299
|
+
checked={filter.types.has(type)}
|
|
300
|
+
onCheckedChange={() => toggleType(type)}
|
|
301
|
+
>
|
|
302
|
+
{getTypeLabel(type)}
|
|
303
|
+
</FilterCheckbox>
|
|
304
|
+
))}
|
|
305
|
+
|
|
306
|
+
<FilterPanelSeparator />
|
|
307
|
+
<FilterPanelLabel>Method</FilterPanelLabel>
|
|
308
|
+
<div className="grid grid-cols-2">
|
|
309
|
+
{HTTP_METHODS.map((method) => (
|
|
310
|
+
<FilterCheckbox
|
|
311
|
+
key={method}
|
|
312
|
+
checked={filter.advanced.methods.has(method)}
|
|
313
|
+
onCheckedChange={() => toggleMethod(method)}
|
|
314
|
+
>
|
|
315
|
+
{method}
|
|
316
|
+
</FilterCheckbox>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<FilterPanelSeparator />
|
|
321
|
+
<FilterPanelLabel>Request Source</FilterPanelLabel>
|
|
322
|
+
{SOURCES.map((source) => (
|
|
323
|
+
<FilterCheckbox
|
|
324
|
+
key={source}
|
|
325
|
+
checked={filter.advanced.sources.has(source)}
|
|
326
|
+
onCheckedChange={() => toggleSource(source)}
|
|
327
|
+
>
|
|
328
|
+
{getSourceLabel(source)}
|
|
329
|
+
</FilterCheckbox>
|
|
330
|
+
))}
|
|
331
|
+
|
|
332
|
+
<FilterPanelSeparator />
|
|
333
|
+
<FilterCheckbox
|
|
334
|
+
checked={filter.advanced.failedOnly}
|
|
335
|
+
onCheckedChange={(checked) =>
|
|
336
|
+
updateAdvancedFilter({ failedOnly: checked })
|
|
96
337
|
}
|
|
97
338
|
>
|
|
98
|
-
|
|
99
|
-
</
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
339
|
+
Failed only
|
|
340
|
+
</FilterCheckbox>
|
|
341
|
+
<FilterCheckbox
|
|
342
|
+
checked={filter.advanced.inFlightOnly}
|
|
343
|
+
onCheckedChange={(checked) =>
|
|
344
|
+
updateAdvancedFilter({ inFlightOnly: checked })
|
|
345
|
+
}
|
|
346
|
+
>
|
|
347
|
+
In-flight only
|
|
348
|
+
</FilterCheckbox>
|
|
349
|
+
<FilterCheckbox
|
|
350
|
+
checked={filter.advanced.overriddenOnly}
|
|
351
|
+
onCheckedChange={(checked) =>
|
|
352
|
+
updateAdvancedFilter({ overriddenOnly: checked })
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
Overridden only
|
|
356
|
+
</FilterCheckbox>
|
|
357
|
+
|
|
358
|
+
<FilterPanelSeparator />
|
|
359
|
+
<div className="grid grid-cols-2 gap-x-2">
|
|
360
|
+
<FilterField
|
|
361
|
+
label="Status"
|
|
362
|
+
value={filter.advanced.status}
|
|
363
|
+
placeholder="200, 2xx, >=400"
|
|
364
|
+
onChange={(status) => updateAdvancedFilter({ status })}
|
|
365
|
+
/>
|
|
366
|
+
<FilterField
|
|
367
|
+
label="Domain"
|
|
368
|
+
value={filter.advanced.domain}
|
|
369
|
+
placeholder="api.example.com"
|
|
370
|
+
onChange={(domain) => updateAdvancedFilter({ domain })}
|
|
371
|
+
/>
|
|
372
|
+
<FilterField
|
|
373
|
+
label="MIME Type"
|
|
374
|
+
value={filter.advanced.contentType}
|
|
375
|
+
placeholder="json"
|
|
376
|
+
onChange={(contentType) =>
|
|
377
|
+
updateAdvancedFilter({ contentType })
|
|
378
|
+
}
|
|
379
|
+
/>
|
|
380
|
+
<FilterField
|
|
381
|
+
label="Min Size"
|
|
382
|
+
value={filter.advanced.minSize}
|
|
383
|
+
placeholder="1024"
|
|
384
|
+
onChange={(minSize) => updateAdvancedFilter({ minSize })}
|
|
385
|
+
/>
|
|
386
|
+
<FilterField
|
|
387
|
+
label="Max Size"
|
|
388
|
+
value={filter.advanced.maxSize}
|
|
389
|
+
placeholder="50000"
|
|
390
|
+
onChange={(maxSize) => updateAdvancedFilter({ maxSize })}
|
|
391
|
+
/>
|
|
392
|
+
<FilterField
|
|
393
|
+
label="Min Duration"
|
|
394
|
+
value={filter.advanced.minDuration}
|
|
395
|
+
placeholder="500"
|
|
396
|
+
onChange={(minDuration) =>
|
|
397
|
+
updateAdvancedFilter({ minDuration })
|
|
398
|
+
}
|
|
399
|
+
/>
|
|
400
|
+
<FilterField
|
|
401
|
+
label="Max Duration"
|
|
402
|
+
value={filter.advanced.maxDuration}
|
|
403
|
+
placeholder="2000"
|
|
404
|
+
onChange={(maxDuration) =>
|
|
405
|
+
updateAdvancedFilter({ maxDuration })
|
|
406
|
+
}
|
|
407
|
+
/>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</FloatingPortal>
|
|
411
|
+
)}
|
|
103
412
|
|
|
104
|
-
{/* Clear Filters */}
|
|
105
413
|
{hasActiveFilters && (
|
|
106
414
|
<Button
|
|
107
415
|
variant="ghost"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Virtuoso } from 'react-virtuoso';
|
|
2
|
+
import {
|
|
3
|
+
BYTES_PER_HEX_ROW,
|
|
4
|
+
formatHexRow,
|
|
5
|
+
rowCountForByteLength,
|
|
6
|
+
} from '../utils/hex';
|
|
7
|
+
|
|
8
|
+
export type HexViewProps = {
|
|
9
|
+
bytes: Uint8Array;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type HexRowProps = {
|
|
13
|
+
offset: string;
|
|
14
|
+
hex: string;
|
|
15
|
+
ascii: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Rendered each scroll tick by virtuoso. Kept inline rather than
|
|
19
|
+
// memoized because the per-row work is dominated by `formatHexRow`
|
|
20
|
+
// which itself is cheap; memoizing wouldn't change cost meaningfully.
|
|
21
|
+
const HexRow = ({ offset, hex, ascii }: HexRowProps) => (
|
|
22
|
+
<div className="flex gap-4 font-mono text-xs leading-snug whitespace-pre">
|
|
23
|
+
<span className="text-gray-500">{offset}</span>
|
|
24
|
+
<span className="text-gray-200">{hex}</span>
|
|
25
|
+
<span className="text-gray-400">{`|${ascii}|`}</span>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Virtualized hex view. Each row is the classic offset / hex / ASCII
|
|
30
|
+
// triple; native text selection covers what's currently rendered
|
|
31
|
+
// (off-screen rows are unmounted, by design). When users need every
|
|
32
|
+
// byte they reach for the Download button on the metadata card.
|
|
33
|
+
export const HexView = ({ bytes }: HexViewProps) => {
|
|
34
|
+
const totalCount = rowCountForByteLength(bytes.byteLength);
|
|
35
|
+
|
|
36
|
+
if (totalCount === 0) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="text-xs text-gray-500 italic">No bytes to display.</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="bg-gray-900 border border-gray-700 rounded">
|
|
44
|
+
<Virtuoso
|
|
45
|
+
style={{ height: 320 }}
|
|
46
|
+
totalCount={totalCount}
|
|
47
|
+
itemContent={(index) => {
|
|
48
|
+
const row = formatHexRow(bytes, index * BYTES_PER_HEX_ROW);
|
|
49
|
+
return <HexRow offset={row.offset} hex={row.hex} ascii={row.ascii} />;
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Download } from 'lucide-react';
|
|
2
|
+
import { KeyValueGrid } from './KeyValueGrid';
|
|
3
|
+
import {
|
|
4
|
+
base64ToBlob,
|
|
5
|
+
deriveFilename,
|
|
6
|
+
downloadBlob,
|
|
7
|
+
readHeader,
|
|
8
|
+
} from '../utils/download';
|
|
9
|
+
import type { RenderCtx } from '../response-renderers/types';
|
|
10
|
+
|
|
11
|
+
export type MetadataCardBody =
|
|
12
|
+
| { kind: 'binary'; base64: string }
|
|
13
|
+
| { kind: 'binary-too-large'; size: number };
|
|
14
|
+
|
|
15
|
+
export type MetadataCardProps = {
|
|
16
|
+
body: MetadataCardBody;
|
|
17
|
+
ctx: RenderCtx;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const formatBytes = (bytes: number): string => {
|
|
21
|
+
if (bytes >= 1024 * 1024) {
|
|
22
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
23
|
+
}
|
|
24
|
+
if (bytes >= 1024) {
|
|
25
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
26
|
+
}
|
|
27
|
+
return `${bytes} bytes`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// base64 inflates ~33%; subtract trailing `=` padding for the true
|
|
31
|
+
// decoded byte count.
|
|
32
|
+
const decodedByteCount = (base64: string): number => {
|
|
33
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
34
|
+
return Math.floor((base64.length * 3) / 4) - padding;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const MetadataCard = ({ body, ctx }: MetadataCardProps) => {
|
|
38
|
+
const decodedSize =
|
|
39
|
+
body.kind === 'binary' ? decodedByteCount(body.base64) : body.size;
|
|
40
|
+
|
|
41
|
+
const contentLengthHeader = readHeader(ctx.headers, 'Content-Length');
|
|
42
|
+
const filename = deriveFilename({
|
|
43
|
+
headers: ctx.headers,
|
|
44
|
+
url: ctx.url,
|
|
45
|
+
contentType: ctx.contentType,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const isDownloadAvailable = body.kind === 'binary';
|
|
49
|
+
const downloadTitle = isDownloadAvailable
|
|
50
|
+
? `Download ${filename}`
|
|
51
|
+
: `Response too large to download (> 5 MB cap, size: ${formatBytes(decodedSize)})`;
|
|
52
|
+
|
|
53
|
+
const handleDownload = () => {
|
|
54
|
+
if (body.kind !== 'binary') return;
|
|
55
|
+
const blob = base64ToBlob(body.base64, ctx.contentType);
|
|
56
|
+
downloadBlob(blob, filename);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex items-start justify-between gap-3 bg-gray-800 border border-gray-700 rounded p-3">
|
|
61
|
+
<div className="flex-1 min-w-0">
|
|
62
|
+
<KeyValueGrid
|
|
63
|
+
items={[
|
|
64
|
+
{
|
|
65
|
+
key: 'Size',
|
|
66
|
+
value: formatBytes(decodedSize),
|
|
67
|
+
},
|
|
68
|
+
...(contentLengthHeader
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
key: 'Content-Length',
|
|
72
|
+
value: contentLengthHeader,
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
: []),
|
|
76
|
+
{
|
|
77
|
+
key: 'Filename',
|
|
78
|
+
value: filename,
|
|
79
|
+
},
|
|
80
|
+
]}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={handleDownload}
|
|
86
|
+
disabled={!isDownloadAvailable}
|
|
87
|
+
title={downloadTitle}
|
|
88
|
+
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-violet-500 text-violet-200 hover:bg-violet-900/40 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent transition-colors"
|
|
89
|
+
>
|
|
90
|
+
<Download className="h-3 w-3" />
|
|
91
|
+
Download
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|