Line data Source code
1 : import * as React from 'react';
2 : import * as styles from './http_request_viewer.module.css';
3 :
4 : import { List, useListCallbackRef } from 'react-window';
5 : import type { ListImperativeAPI, RowComponentProps } from 'react-window';
6 :
7 : import { CheckIcon, ChevronDownIcon, ChevronUpIcon, CopyIcon, TrashIcon, XIcon } from '../foundations/symbol_icon.js';
8 : import { ButtonSize, ButtonVariant, IconButton } from '../foundations/button.js';
9 : import { AnchoredOverlay } from '../foundations/anchored_overlay.js';
10 : import { OverlaySize } from '../foundations/overlay.js';
11 : import { AnchorAlignment, AnchorSide } from '../foundations/anchored_position.js';
12 : import { JsonView } from '../json/json_view.js';
13 : import { observeSize } from '../foundations/size_observer.js';
14 : import { VerticalScrollShadows } from '../foundations/vertical_scroll_shadows.js';
15 : import { useKeyEvents } from '../../utils/key_events.js';
16 : import { useHttpRequestLog } from '../../platform/http/http_client_provider.js';
17 : import {
18 : formatCurlCommand,
19 : type HttpBodyRecord,
20 : type HttpRequestEntry,
21 : type HttpRequestLog,
22 : maskHeaders,
23 : } from '../../platform/http/http_request_log.js';
24 :
25 : export const ROW_HEIGHT = 32;
26 :
27 : /// The status of a request
28 1 : export function getRequestStatusLabel(entry: HttpRequestEntry): string {
29 1 : if (entry.status != null) return entry.status.toString();
30 1 : if (entry.error != null) return 'failed';
31 1 : return 'pending';
32 1 : }
33 :
34 : /// The milliseconds until the response headers arrived
35 1 : function getRequestDurationMs(entry: HttpRequestEntry): number | null {
36 1 : const end = entry.respondedAt ?? entry.failedAt;
37 1 : return end == null ? null : end.getTime() - entry.sentAt.getTime();
38 1 : }
39 :
40 : /// A body in the detail view, a whole JSON text is shown parsed
41 1 : function bodyToObject(body: HttpBodyRecord | null): unknown {
42 1 : if (body == null) return null;
43 1 : if (body.kind != 'text') return body;
44 1 : if (!body.truncated) {
45 1 : try {
46 1 : const parsed: unknown = JSON.parse(body.text);
47 1 : if (parsed != null && typeof parsed == 'object') return { ...body, text: undefined, json: parsed };
48 1 : } catch {
49 : // Not JSON, show the text
50 1 : }
51 : }
52 1 : return body;
53 1 : }
54 :
55 1 : function entryToObject(entry: HttpRequestEntry): object {
56 1 : return {
57 1 : requestId: entry.requestId,
58 1 : method: entry.method,
59 1 : url: entry.url,
60 1 : status: entry.status,
61 1 : statusText: entry.statusText,
62 1 : error: entry.error,
63 1 : timing: {
64 1 : sentAt: entry.sentAt.toISOString(),
65 1 : respondedAt: entry.respondedAt?.toISOString() ?? null,
66 1 : bodyReceivedAt: entry.bodyReceivedAt?.toISOString() ?? null,
67 1 : failedAt: entry.failedAt?.toISOString() ?? null,
68 1 : durationMs: getRequestDurationMs(entry),
69 1 : },
70 1 : request: {
71 1 : headers: Object.fromEntries(maskHeaders(entry.requestHeaders)),
72 1 : body: bodyToObject(entry.requestBody),
73 1 : },
74 1 : response: {
75 1 : headers: entry.responseHeaders != null ? Object.fromEntries(maskHeaders(entry.responseHeaders)) : null,
76 1 : body: bodyToObject(entry.responseBody),
77 1 : },
78 1 : };
79 1 : }
80 :
81 : /// Copy the request as curl command, the command includes credentials
82 1 : export function CopyCurlButton(props: { entry: HttpRequestEntry; size?: ButtonSize }) {
83 1 : const [copied, setCopied] = React.useState(false);
84 1 : React.useEffect(() => {
85 1 : if (!copied) return;
86 1 : const timer = setTimeout(() => setCopied(false), 2000);
87 1 : return () => clearTimeout(timer);
88 1 : }, [copied]);
89 1 : const copy = React.useCallback((e: React.MouseEvent) => {
90 1 : e.stopPropagation();
91 1 : navigator.clipboard.writeText(formatCurlCommand(props.entry)).then(
92 1 : () => setCopied(true),
93 1 : () => { /* The clipboard is not available */ },
94 1 : );
95 1 : }, [props.entry]);
96 1 : return (
97 1 : <IconButton
98 1 : variant={ButtonVariant.Invisible}
99 1 : size={props.size}
100 1 : aria-label="Copy as curl"
101 1 : description="Copy as curl, includes credentials"
102 1 : onClick={copy}
103 1 : data-testid="http-request-copy-curl"
104 : >
105 1 : {copied ? <CheckIcon /> : <CopyIcon />}
106 1 : </IconButton>
107 : );
108 1 : }
109 :
110 : interface RequestRowProps {
111 : entries: readonly HttpRequestEntry[];
112 : showDetail: (index: number) => void;
113 : selectedIndex: number;
114 : }
115 :
116 1 : const RequestRow = (props: RowComponentProps<RequestRowProps>) => {
117 1 : const { entries, showDetail, selectedIndex } = props;
118 1 : const entry = entries[props.index];
119 1 : if (!entry) {
120 0 : return <div style={props.style} />;
121 : }
122 1 : const durationMs = getRequestDurationMs(entry);
123 1 : const failed = entry.error != null || (entry.status != null && entry.status >= 400);
124 1 : return (
125 1 : <div
126 1 : className={`${styles.request_row} ${props.index === selectedIndex ? styles.request_row_selected : ''}`}
127 1 : style={props.style}
128 1 : onClick={() => showDetail(props.index)}
129 1 : data-testid="http-request-row"
130 : >
131 1 : <div className={styles.request_row_main}>
132 1 : <div className={styles.cell}>{entry.sentAt.toLocaleTimeString('en-GB', { hour12: false })}</div>
133 1 : <div className={styles.cell}>{entry.method}</div>
134 1 : <div className={`${styles.cell} ${styles.cell_status} ${failed ? styles.cell_status_failed : ''}`}>
135 1 : {getRequestStatusLabel(entry)}
136 1 : </div>
137 1 : <div className={`${styles.cell} ${styles.cell_duration}`}>{durationMs != null ? `${durationMs} ms` : '—'}</div>
138 1 : <div className={styles.cell} title={entry.url}>{entry.url}</div>
139 1 : <div className={styles.cell_action}>
140 1 : <CopyCurlButton entry={entry} size={ButtonSize.Small} />
141 1 : </div>
142 1 : </div>
143 1 : </div>
144 : );
145 1 : };
146 :
147 : export interface HttpRequestViewerDependencies {
148 : List: React.ComponentType<any>;
149 : useListCallbackRef(initialValue: ListImperativeAPI | null): [ListImperativeAPI | null, React.Dispatch<React.SetStateAction<ListImperativeAPI | null>>];
150 : }
151 :
152 1 : const DEFAULT_DEPENDENCIES: HttpRequestViewerDependencies = {
153 1 : List,
154 1 : useListCallbackRef,
155 1 : };
156 :
157 1 : const EMPTY_SNAPSHOT: readonly HttpRequestEntry[] = [];
158 1 : const subscribeNothing = () => () => { };
159 1 : const getEmptySnapshot = () => EMPTY_SNAPSHOT;
160 :
161 : /// The outgoing http requests of the app
162 1 : export function HttpRequestViewer(props: {
163 : onClose: () => void;
164 : log?: HttpRequestLog | null;
165 : dependencies?: HttpRequestViewerDependencies;
166 1 : }) {
167 1 : const providedLog = useHttpRequestLog();
168 1 : const log = props.log ?? providedLog;
169 1 : const entries = React.useSyncExternalStore(log?.subscribe ?? subscribeNothing, log?.getSnapshot ?? getEmptySnapshot);
170 1 : const dependencies = props.dependencies ?? DEFAULT_DEPENDENCIES;
171 1 : const ListComponent = dependencies.List;
172 :
173 1 : const containerRef = React.useRef<HTMLDivElement>(null);
174 1 : const containerSize = observeSize(containerRef);
175 1 : const containerWidth = containerSize?.width ?? 200;
176 1 : const containerHeight = containerSize?.height ?? 100;
177 :
178 : // The request in the detail view, by id since the entries are replaced when requests progress
179 1 : const [selectedId, setSelectedId] = React.useState<number | null>(null);
180 1 : const selectedIndex = selectedId == null ? -1 : entries.findIndex(e => e.requestId === selectedId);
181 1 : const selectedEntry = selectedIndex >= 0 ? entries[selectedIndex] : null;
182 :
183 1 : const closeRef = React.useRef<HTMLButtonElement>(null);
184 1 : const closeDetail = React.useCallback(() => setSelectedId(null), []);
185 1 : const showDetail = React.useCallback((index: number) => {
186 1 : const entry = entries[index];
187 1 : if (entry) setSelectedId(entry.requestId);
188 1 : }, [entries]);
189 1 : const showPrevious = React.useCallback(() => {
190 1 : if (selectedIndex > 0) showDetail(selectedIndex - 1);
191 1 : }, [selectedIndex, showDetail]);
192 1 : const showNext = React.useCallback(() => {
193 1 : if (selectedIndex >= 0 && selectedIndex < entries.length - 1) showDetail(selectedIndex + 1);
194 1 : }, [selectedIndex, entries.length, showDetail]);
195 :
196 1 : useKeyEvents(
197 1 : selectedEntry
198 1 : ? [
199 1 : {
200 1 : key: 'ArrowUp',
201 1 : callback: (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); showPrevious(); },
202 1 : capture: true,
203 1 : },
204 1 : {
205 1 : key: 'ArrowDown',
206 1 : callback: (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); showNext(); },
207 1 : capture: true,
208 1 : },
209 1 : ]
210 1 : : []
211 1 : );
212 :
213 : // Follow new requests, keep the selected request in view
214 1 : const [listApi, setListApi] = dependencies.useListCallbackRef(null);
215 1 : const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
216 1 : React.useEffect(() => {
217 1 : if (selectedIndex >= 0 && listApi) {
218 1 : listApi.scrollToRow({ index: selectedIndex, align: 'center' });
219 1 : } else if (listApi && entries.length > 0) {
220 1 : listApi.scrollToRow({ index: entries.length - 1, align: 'end' });
221 : }
222 1 : }, [entries.length, selectedIndex, listApi]);
223 :
224 1 : const rowProps = React.useMemo<RequestRowProps>(() => ({
225 1 : entries,
226 1 : showDetail,
227 1 : selectedIndex,
228 1 : }), [entries, showDetail, selectedIndex]);
229 :
230 1 : const detailObject = React.useMemo<object | null>(
231 1 : () => selectedEntry ? entryToObject(selectedEntry) : null,
232 1 : [selectedEntry]
233 1 : );
234 :
235 1 : return (
236 1 : <>
237 1 : <div className={styles.overlay}>
238 1 : <div className={styles.header_container}>
239 1 : <div className={styles.header_left_container}>
240 1 : <div className={styles.title}>Requests</div>
241 1 : </div>
242 1 : <div className={styles.header_right_container}>
243 1 : <IconButton
244 1 : variant={ButtonVariant.Invisible}
245 1 : aria-label="Clear requests"
246 1 : onClick={() => { setSelectedId(null); log?.clear(); }}
247 1 : disabled={entries.length === 0}
248 : >
249 1 : <TrashIcon />
250 1 : </IconButton>
251 1 : <IconButton
252 1 : ref={closeRef}
253 1 : variant={ButtonVariant.Invisible}
254 1 : aria-label="close-overlay"
255 1 : onClick={props.onClose}
256 : >
257 1 : <XIcon />
258 1 : </IconButton>
259 1 : </div>
260 1 : </div>
261 1 : <div className={styles.request_header_row}>
262 1 : <div className={styles.request_row_main}>
263 1 : <div className={styles.cell}>Time</div>
264 1 : <div className={styles.cell}>Method</div>
265 1 : <div className={styles.cell}>Status</div>
266 1 : <div className={`${styles.cell} ${styles.cell_duration}`}>Duration</div>
267 1 : <div className={styles.cell}>URL</div>
268 1 : </div>
269 1 : </div>
270 1 : <div className={styles.request_grid_container} ref={containerRef}>
271 1 : {entries.length === 0 ? (
272 1 : <div className={styles.empty_state}>Nothing to see here</div>
273 : ) : (
274 1 : <ListComponent
275 1 : listRef={setListApi}
276 1 : style={{ width: containerWidth, height: containerHeight }}
277 1 : rowCount={entries.length}
278 1 : rowHeight={ROW_HEIGHT}
279 1 : rowComponent={RequestRow}
280 1 : rowProps={rowProps}
281 1 : />
282 : )}
283 1 : <VerticalScrollShadows
284 1 : getScrollElement={getScrollElement}
285 1 : refreshKey={entries.length}
286 1 : />
287 1 : </div>
288 1 : </div>
289 1 : <AnchoredOverlay
290 1 : renderAnchor={null}
291 1 : anchorRef={containerRef}
292 1 : returnFocusRef={closeRef}
293 1 : open={selectedEntry !== null}
294 1 : onClose={closeDetail}
295 1 : width={OverlaySize.L}
296 1 : align={AnchorAlignment.Start}
297 1 : side={AnchorSide.OutsideLeft}
298 : >
299 1 : <div className={styles.detail_modal}>
300 1 : <div className={styles.detail_modal_main}>
301 1 : <div className={styles.detail_modal_content}>
302 1 : {detailObject && <JsonView value={detailObject} />}
303 1 : </div>
304 1 : </div>
305 1 : <div className={styles.detail_modal_sidebar}>
306 1 : <div className={styles.detail_modal_sidebar_top}>
307 1 : <IconButton
308 1 : variant={ButtonVariant.Invisible}
309 1 : aria-label="Close"
310 1 : onClick={closeDetail}
311 : >
312 1 : <XIcon />
313 1 : </IconButton>
314 1 : {selectedEntry && <CopyCurlButton entry={selectedEntry} />}
315 1 : </div>
316 1 : <div className={styles.detail_modal_sidebar_bottom}>
317 1 : <IconButton
318 1 : variant={ButtonVariant.Invisible}
319 1 : aria-label="Previous entry"
320 1 : onClick={showPrevious}
321 1 : disabled={selectedIndex <= 0}
322 : >
323 1 : <ChevronUpIcon />
324 1 : </IconButton>
325 1 : <IconButton
326 1 : variant={ButtonVariant.Invisible}
327 1 : aria-label="Next entry"
328 1 : onClick={showNext}
329 1 : disabled={selectedIndex >= entries.length - 1}
330 : >
331 1 : <ChevronDownIcon />
332 1 : </IconButton>
333 1 : </div>
334 1 : </div>
335 1 : </div>
336 1 : </AnchoredOverlay>
337 1 : </>
338 : );
339 1 : }
|