LCOV - code coverage report
Current view: top level - dashql-app/src/ui/http - http_request_viewer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.6 % 259 258
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1