LCOV - code coverage report
Current view: top level - dashql-app/src/session - trace_log_viewer.tsx Coverage Total Hit
Test: DashQL 6e74948 Lines: 100.0 % 123 123
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from '../ui/logs/log_viewer.module.css';
       3              : 
       4              : import { List, useListRef } from 'react-window';
       5              : 
       6              : import { LogRecord } from '../platform/logger/log_buffer.js';
       7              : import { pollLogVersion, useLogger } from '../platform/logger/logger_provider.js';
       8              : import { observeSize } from '../ui/foundations/size_observer.js';
       9              : import { AnchorAlignment, AnchorSide } from '../ui/foundations/anchored_position.js';
      10              : import { useKeyEvents } from '../utils/key_events.js';
      11              : import { LogJsonModal } from '../ui/logs/log_json_modal.js';
      12              : import {
      13              :     LogRow,
      14              :     LogRowProps,
      15              :     ROW_HEIGHT,
      16              : } from '../ui/logs/log_viewer.js';
      17              : 
      18              : interface TraceLogViewerProps {
      19              :     traceId?: number;
      20              :     /// Fixed pixel height of the log viewport. Ignored when `maxRows` or `fill` is set.
      21              :     height?: number;
      22              :     /// Scrollless preview mode: the viewport auto-expands to fit the current row count and caps at
      23              :     /// this many rows. Beyond the cap the inner scrollbar is suppressed and only the last `maxRows`
      24              :     /// rows are shown (no nested scroller). Takes precedence over `height`.
      25              :     maxRows?: number;
      26              :     /// Fill mode: the viewport fills its parent's height (which must be constrained) and scrolls
      27              :     /// normally. Used by the Details Status tab, where the log should occupy the full card. Takes
      28              :     /// precedence over `height` (but not `maxRows`).
      29              :     fill?: boolean;
      30              : }
      31              : 
      32            1 : export const TraceLogViewer: React.FC<TraceLogViewerProps> = (props: TraceLogViewerProps) => {
      33            1 :     const logger = useLogger();
      34              :     // The trace subscription drives the re-renders, poll the log version only when showing all logs
      35            1 :     const logVersion = pollLogVersion(100, props.traceId === undefined);
      36              : 
      37              :     // Maintain filtered log records for trace-specific viewing.
      38              :     // We append to a mutable array and bump a version, copying the array per record is quadratic.
      39            1 :     const filteredLogsRef = React.useRef<LogRecord[]>([]);
      40            1 :     const [filteredLogsVersion, setFilteredLogsVersion] = React.useState<number>(0);
      41            1 :     const filteredLogs = filteredLogsRef.current;
      42              : 
      43              :     // Track which log record to show in JSON modal (record and index as tuple)
      44            1 :     const [jsonModalState, setJsonModalState] = React.useState<[LogRecord | null, number]>([null, -1]);
      45            1 :     const [jsonModalRecord, jsonModalRecordIndex] = jsonModalState;
      46              : 
      47              :     // Close the modal
      48            1 :     const closeJsonModal = React.useCallback(() => {
      49            1 :         setJsonModalState([null, -1]);
      50            1 :     }, []);
      51              : 
      52              :     // Subscribe to trace-specific logs if traceId is provided
      53            1 :     React.useEffect(() => {
      54            1 :         if (props.traceId === undefined) {
      55            1 :             return;
      56              :         }
      57              : 
      58              :         // Get initial logs for this trace
      59            1 :         const initialLogs = logger.buffer.collectTraceLogs(props.traceId);
      60            1 :         filteredLogsRef.current = initialLogs;
      61            1 :         setFilteredLogsVersion(v => v + 1);
      62              : 
      63              :         // Subscribe to new logs for this trace
      64            1 :         const observer = (record: LogRecord) => {
      65            1 :             initialLogs.push(record);
      66            1 :             setFilteredLogsVersion(v => v + 1);
      67            1 :         };
      68            1 :         logger.buffer.subscribeTrace(props.traceId, observer);
      69              : 
      70              :         // Cleanup subscription
      71            1 :         return () => {
      72            1 :             logger.buffer.unsubscribeTrace(props.traceId!, observer);
      73            1 :         };
      74            1 :     }, [props.traceId, logger]);
      75              : 
      76              :     // The number of rows currently displayed.
      77            1 :     const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
      78              : 
      79              :     // Determine log container dimensions. When `maxRows` is set (scrollless preview mode), the
      80              :     // viewport auto-expands to fit the current rows (so it grows as logs stream in) and caps at
      81              :     // `maxRows` rows; beyond the cap the inner scrollbar is suppressed and the list stays pinned to
      82              :     // the last `maxRows` rows (see the scroll-to-last effect below), so the feed's own scroller
      83              :     // stays the only one. Otherwise it falls back to the fixed `height` prop and scrolls normally.
      84            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
      85            1 :     const containerSize = observeSize(containerRef);
      86            1 :     const containerWidth = containerSize?.width ?? 200;
      87              :     // In fill mode the container stretches to its parent (height: 100%) and the inner list is sized
      88              :     // to the measured height so react-window has an explicit pixel viewport to scroll within.
      89            1 :     const containerHeight = props.maxRows != null
      90            1 :         ? Math.min(rowCount, props.maxRows) * ROW_HEIGHT
      91            1 :         : props.fill
      92            1 :             ? (containerSize?.height ?? 0)
      93            1 :             : (props.height ?? 200);
      94              : 
      95              :     // Redraw whenever the log version changes or filtered logs change
      96            1 :     const seenLogRows = React.useRef<number>(0);
      97            1 :     const listRef = useListRef(null);
      98            1 :     React.useEffect(() => {
      99            1 :         if (!listRef.current) return;
     100            1 :         seenLogRows.current = rowCount;
     101            1 :         if (rowCount === 0) return;
     102              : 
     103              :         // Scroll to the last row. Unlike the modal log viewer, our container height is a fixed
     104              :         // prop rather than a measured size, so the effect never re-fires after layout settles.
     105              :         // We scroll synchronously and again on the next frame, once react-window has committed the
     106              :         // freshly-appended rows, so the view reliably sticks to the bottom as new logs stream in.
     107            1 :         const scrollToLast = () => listRef.current?.scrollToRow({ index: rowCount - 1, align: 'end' });
     108            1 :         scrollToLast();
     109            1 :         const raf = requestAnimationFrame(scrollToLast);
     110            1 :         return () => cancelAnimationFrame(raf);
     111            1 :     }, [logVersion, containerHeight, filteredLogs, filteredLogsVersion, props.traceId, logger]);
     112              : 
     113              :     // Helper to show JSON modal for a log record
     114            1 :     const showJsonRecord = React.useCallback((rowIndex: number) => {
     115            1 :         let record: LogRecord | null;
     116            1 :         if (props.traceId !== undefined) {
     117            1 :             record = filteredLogs[rowIndex] ?? null;
     118              :         } else {
     119            1 :             record = logger.buffer.at(rowIndex);
     120              :         }
     121            1 :         if (record) {
     122            1 :             setJsonModalState([record, rowIndex]);
     123              :         }
     124            1 :     }, [props.traceId, filteredLogs, filteredLogsVersion, logger]);
     125              : 
     126              :     // Navigate to previous log record
     127            1 :     const showPreviousRecord = React.useCallback(() => {
     128            1 :         if (jsonModalRecordIndex <= 0) return;
     129            1 :         showJsonRecord(jsonModalRecordIndex - 1);
     130            1 :     }, [jsonModalRecordIndex, showJsonRecord]);
     131              : 
     132              :     // Navigate to next log record
     133            1 :     const showNextRecord = React.useCallback(() => {
     134            1 :         const maxIndex = props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1;
     135            1 :         if (jsonModalRecordIndex >= maxIndex) return;
     136            1 :         showJsonRecord(jsonModalRecordIndex + 1);
     137            1 :     }, [jsonModalRecordIndex, showJsonRecord, props.traceId, filteredLogs, filteredLogsVersion, logger]);
     138              : 
     139              :     // Keyboard navigation when modal is open
     140            1 :     useKeyEvents(
     141            1 :         jsonModalRecord
     142            1 :             ? [
     143            1 :                 {
     144            1 :                     key: 'ArrowUp',
     145            1 :                     callback: (e: KeyboardEvent) => {
     146            1 :                         e.preventDefault();
     147            1 :                         e.stopPropagation();
     148            1 :                         showPreviousRecord();
     149            1 :                     },
     150            1 :                 },
     151            1 :                 {
     152            1 :                     key: 'ArrowDown',
     153            1 :                     callback: (e: KeyboardEvent) => {
     154            1 :                         e.preventDefault();
     155            1 :                         e.stopPropagation();
     156            1 :                         showNextRecord();
     157            1 :                     },
     158            1 :                 },
     159            1 :             ]
     160            1 :             : []
     161            1 :     );
     162              : 
     163              :     // Scroll to the selected row when it changes
     164            1 :     React.useEffect(() => {
     165            1 :         if (jsonModalRecordIndex >= 0 && listRef.current) {
     166            1 :             listRef.current.scrollToRow({
     167            1 :                 index: jsonModalRecordIndex,
     168            1 :                 align: 'center',
     169            1 :             });
     170              :         }
     171            1 :     }, [jsonModalRecordIndex]);
     172              : 
     173              :     // Row props
     174            1 :     const rowProps = React.useMemo<LogRowProps>(() => ({
     175            1 :         logger,
     176            1 :         showJsonRecord,
     177            1 :         selectedRecordIndex: jsonModalRecordIndex,
     178            1 :         filteredLogs: props.traceId !== undefined ? filteredLogs : undefined,
     179            1 :     }), [logger, showJsonRecord, jsonModalRecordIndex, props.traceId, filteredLogs, filteredLogsVersion]);
     180              : 
     181            1 :     return (
     182            1 :         <>
     183            1 :             <div className={styles.log_grid_container} ref={containerRef} style={{ height: props.fill ? '100%' : containerHeight, position: 'relative' }}>
     184            1 :                 <List
     185            1 :                     listRef={listRef}
     186              :                     // In scrollless preview mode (`maxRows`) the viewport is pinned to the last rows
     187              :                     // and the feed's own scroller is the only one, so suppress the inner scrollbar.
     188              :                     // Otherwise scroll normally — pass 'auto' explicitly rather than `undefined`, which
     189              :                     // would override react-window's default `overflowY: 'auto'` and disable scrolling.
     190            1 :                     style={{ width: containerWidth, height: containerHeight, overflowY: props.maxRows != null ? 'hidden' : 'auto' }}
     191            1 :                     rowCount={rowCount}
     192            1 :                     rowHeight={ROW_HEIGHT}
     193            1 :                     rowComponent={LogRow}
     194            1 :                     rowProps={rowProps}
     195            1 :                 />
     196            1 :             </div>
     197            1 :             <LogJsonModal
     198            1 :                 record={jsonModalRecord}
     199            1 :                 recordIndex={jsonModalRecordIndex}
     200            1 :                 maxIndex={props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1}
     201            1 :                 anchorRef={containerRef}
     202            1 :                 align={AnchorAlignment.Start}
     203            1 :                 side={AnchorSide.OutsideLeft}
     204            1 :                 onClose={closeJsonModal}
     205            1 :                 onPrevious={showPreviousRecord}
     206            1 :                 onNext={showNextRecord}
     207            1 :             />
     208            1 :         </>
     209              :     );
     210            1 : }
        

Generated by: LCOV version 2.0-1