LCOV - code coverage report
Current view: top level - dashql-app/src/ui/logs - log_viewer.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.2 % 238 236
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as styles from './log_viewer.module.css';
       3              : 
       4              : import { List, useListCallbackRef } from 'react-window';
       5              : import type { RowComponentProps } from 'react-window';
       6              : import { XIcon, DownloadIcon } from '../foundations/symbol_icon.js';
       7              : 
       8              : import { getLogLevelName, LogRecord } from '../../platform/logger/log_buffer.js';
       9              : import { pollLogVersion, useLogger } from '../../platform/logger/logger_provider.js';
      10              : import { observeSize } from '../foundations/size_observer.js';
      11              : import { ButtonVariant, IconButton } from '../foundations/button.js';
      12              : import { AnchorAlignment, AnchorSide } from '../foundations/anchored_position.js';
      13              : import { useKeyEvents } from '../../utils/key_events.js';
      14              : import { LogJsonModal } from './log_json_modal.js';
      15              : import { useFileDownloader } from '../../platform/file/file_downloader_hook.js';
      16              : import { VerticalScrollShadows } from '../foundations/vertical_scroll_shadows.js';
      17              : 
      18              : export const ROW_HEIGHT = 32;
      19              : 
      20              : /// Formatting a timestamp with toLocaleTimeString creates a new formatter for every row
      21            1 : const TIMESTAMP_FORMAT = new Intl.DateTimeFormat('en-GB', {
      22            1 :     hour: 'numeric',
      23            1 :     minute: 'numeric',
      24            1 :     second: 'numeric',
      25            1 :     hour12: false,
      26            1 : });
      27              : 
      28            1 : export function formatLogTimestamp(timestamp: number): string {
      29            1 :     return TIMESTAMP_FORMAT.format(timestamp);
      30            1 : }
      31              : 
      32              : export interface LogRowProps {
      33              :     logger: ReturnType<typeof useLogger>;
      34              :     showJsonRecord: (rowIndex: number) => void;
      35              :     selectedRecordIndex: number;
      36              :     filteredLogs?: LogRecord[];
      37              : }
      38              : 
      39            1 : export const LogRow = (props: RowComponentProps<LogRowProps>) => {
      40            1 :     const { logger, showJsonRecord, selectedRecordIndex, filteredLogs } = props;
      41            1 :     const rowIndex = props.index;
      42              : 
      43              :     // Use filtered logs if provided, otherwise use the full buffer
      44            1 :     let record: LogRecord | null;
      45            1 :     if (filteredLogs) {
      46            1 :         record = filteredLogs[rowIndex] ?? null;
      47            1 :     } else {
      48            1 :         if (rowIndex >= logger.buffer.length) {
      49            0 :             return <div style={props.style} />;
      50              :         }
      51            1 :         record = logger.buffer.at(rowIndex);
      52            1 :     }
      53              : 
      54            1 :     if (!record) {
      55            0 :         return <div style={props.style} />;
      56              :     }
      57              : 
      58            1 :     const isSelected = rowIndex === selectedRecordIndex;
      59            1 :     return (
      60            1 :         <div
      61            1 :             className={`${styles.log_row} ${isSelected ? styles.log_row_selected : ''}`}
      62            1 :             style={props.style}
      63            1 :             onClick={() => showJsonRecord(rowIndex)}
      64              :         >
      65            1 :             <div className={styles.log_row_main}>
      66            1 :                 <div className={styles.log_cell_timestamp}>
      67            1 :                     {formatLogTimestamp(record.timestamp)}
      68            1 :                 </div>
      69            1 :                 <div className={styles.log_cell_level}>
      70            1 :                     {getLogLevelName(record.level)}
      71            1 :                 </div>
      72              : 
      73            1 :                 <div className={styles.log_cell_message} title={record.message}>
      74            1 :                     {record.message}
      75            1 :                 </div>
      76            1 :             </div>
      77            1 :         </div>
      78              :     );
      79            1 : };
      80              : 
      81              : interface LogViewerProps {
      82              :     onClose: () => void;
      83              :     traceId?: number;
      84              : }
      85              : 
      86            1 : export const LogViewer: React.FC<LogViewerProps> = (props: LogViewerProps) => {
      87            1 :     const logger = useLogger();
      88            1 :     const logVersion = pollLogVersion(100);
      89            1 :     const fileDownloader = useFileDownloader();
      90              : 
      91              :     // Maintain filtered log records for trace-specific viewing.
      92              :     // We append to a mutable array and bump a version, copying the array per record is quadratic.
      93            1 :     const filteredLogsRef = React.useRef<LogRecord[]>([]);
      94            1 :     const [filteredLogsVersion, setFilteredLogsVersion] = React.useState<number>(0);
      95            1 :     const filteredLogs = filteredLogsRef.current;
      96              : 
      97              :     // Track which log record to show in JSON modal (record and index as tuple)
      98            1 :     const [jsonModalState, setJsonModalState] = React.useState<[LogRecord | null, number]>([null, -1]);
      99            1 :     const [jsonModalRecord, jsonModalRecordIndex] = jsonModalState;
     100              : 
     101              :     // Close the modal
     102            1 :     const closeRef = React.useRef<HTMLButtonElement>(null);
     103            1 :     const closeJsonModal = React.useCallback(() => {
     104            1 :         setJsonModalState([null, -1]);
     105            1 :     }, []);
     106              : 
     107              :     // Download logs as JSON
     108            1 :     const downloadLogs = React.useCallback(async () => {
     109            1 :         try {
     110              :             // Collect all logs (either filtered by trace or all logs)
     111            1 :             const logsToExport: LogRecord[] = [];
     112              : 
     113            1 :             if (props.traceId !== undefined) {
     114              :                 // Export filtered logs for the trace
     115            1 :                 logsToExport.push(...filteredLogs);
     116            1 :             } else {
     117              :                 // Export all logs from the buffer
     118            1 :                 const totalLogs = logger.buffer.length;
     119            1 :                 for (let i = 0; i < totalLogs; i++) {
     120            1 :                     const record = logger.buffer.at(i);
     121            1 :                     if (record) {
     122            1 :                         logsToExport.push(record);
     123              :                     }
     124            1 :                 }
     125            1 :             }
     126              : 
     127              :             // Convert logs to JSON with pretty printing
     128            1 :             const jsonContent = JSON.stringify(logsToExport, null, 2);
     129            1 :             const encoder = new TextEncoder();
     130            1 :             const uint8Array = encoder.encode(jsonContent);
     131              : 
     132              :             // Generate filename with timestamp
     133            1 :             const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
     134            1 :             const filename = props.traceId !== undefined
     135            1 :                 ? `dashql-logs-trace-${props.traceId}-${timestamp}.json`
     136            1 :                 : `dashql-logs-${timestamp}.json`;
     137              : 
     138              :             // Download the file
     139            1 :             await fileDownloader.downloadBufferAsFile(uint8Array, filename);
     140            1 :         } catch (error) {
     141            1 :             console.error('Failed to download logs:', error);
     142            1 :         }
     143            1 :     }, [logger, fileDownloader, props.traceId, filteredLogs, filteredLogsVersion]);
     144              : 
     145              :     // Subscribe to trace-specific logs if traceId is provided
     146            1 :     React.useEffect(() => {
     147            1 :         if (props.traceId === undefined) {
     148            1 :             return;
     149              :         }
     150              : 
     151              :         // Get initial logs for this trace
     152            1 :         const initialLogs = logger.buffer.collectTraceLogs(props.traceId);
     153            1 :         filteredLogsRef.current = initialLogs;
     154            1 :         setFilteredLogsVersion(v => v + 1);
     155              : 
     156              :         // Subscribe to new logs for this trace
     157            1 :         const observer = (record: LogRecord) => {
     158            1 :             initialLogs.push(record);
     159            1 :             setFilteredLogsVersion(v => v + 1);
     160            1 :         };
     161            1 :         logger.buffer.subscribeTrace(props.traceId, observer);
     162              : 
     163              :         // Cleanup subscription
     164            1 :         return () => {
     165            1 :             logger.buffer.unsubscribeTrace(props.traceId!, observer);
     166            1 :         };
     167            1 :     }, [props.traceId, logger]);
     168              : 
     169              :     // Determine log container dimensions
     170            1 :     const containerRef = React.useRef<HTMLDivElement>(null);
     171            1 :     const containerSize = observeSize(containerRef);
     172            1 :     const containerWidth = containerSize?.width ?? 200;
     173            1 :     const containerHeight = containerSize?.height ?? 100;
     174              : 
     175              :     // Track whether the list is scrolled to the bottom
     176            1 :     const [listApi, setListApi] = useListCallbackRef(null);
     177            1 :     const getScrollElement = React.useCallback(() => listApi?.element ?? null, [listApi]);
     178            1 :     const followTail = React.useRef<boolean>(true);
     179            1 :     React.useEffect(() => {
     180            1 :         const element = listApi?.element;
     181            1 :         if (!element) {
     182            1 :             return;
     183              :         }
     184            1 :         const onScroll = () => {
     185            1 :             followTail.current = element.scrollTop + element.clientHeight >= element.scrollHeight - ROW_HEIGHT / 2;
     186            1 :         };
     187            1 :         element.addEventListener('scroll', onScroll, { passive: true });
     188            1 :         return () => element.removeEventListener('scroll', onScroll);
     189            1 :     }, [listApi]);
     190              : 
     191              :     // Follow new log rows if the list was at the bottom and no record is inspected
     192            1 :     const isInspectingRecord = jsonModalRecord !== null;
     193            1 :     React.useEffect(() => {
     194            1 :         if (listApi && followTail.current && !isInspectingRecord) {
     195            1 :             const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
     196              : 
     197              :             // Scroll to last row, an empty list has none
     198            1 :             if (rowCount > 0) {
     199            1 :                 listApi.scrollToRow({
     200            1 :                     index: rowCount - 1,
     201            1 :                     align: 'end',
     202            1 :                 });
     203              :             }
     204            1 :         }
     205            1 :     }, [logVersion, containerHeight, filteredLogs, filteredLogsVersion, props.traceId, logger, listApi, isInspectingRecord]);
     206              : 
     207              :     // Helper to show JSON modal for a log record
     208            1 :     const showJsonRecord = React.useCallback((rowIndex: number) => {
     209            1 :         let record: LogRecord | null;
     210            1 :         if (props.traceId !== undefined) {
     211            1 :             record = filteredLogs[rowIndex] ?? null;
     212              :         } else {
     213            1 :             record = logger.buffer.at(rowIndex);
     214              :         }
     215            1 :         if (record) {
     216            1 :             setJsonModalState([record, rowIndex]);
     217              :         }
     218            1 :     }, [props.traceId, filteredLogs, filteredLogsVersion, logger]);
     219              : 
     220              :     // Navigate to previous log record
     221            1 :     const showPreviousRecord = React.useCallback(() => {
     222            1 :         if (jsonModalRecordIndex <= 0) return;
     223            1 :         showJsonRecord(jsonModalRecordIndex - 1);
     224            1 :     }, [jsonModalRecordIndex, showJsonRecord]);
     225              : 
     226              :     // Navigate to next log record
     227            1 :     const showNextRecord = React.useCallback(() => {
     228            1 :         const maxIndex = props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1;
     229            1 :         if (jsonModalRecordIndex >= maxIndex) return;
     230            1 :         showJsonRecord(jsonModalRecordIndex + 1);
     231            1 :     }, [jsonModalRecordIndex, showJsonRecord, props.traceId, filteredLogs, filteredLogsVersion, logger]);
     232              : 
     233              :     // Keyboard navigation when modal is open
     234            1 :     useKeyEvents(
     235            1 :         jsonModalRecord
     236            1 :             ? [
     237            1 :                 {
     238            1 :                     key: 'ArrowUp',
     239            1 :                     callback: (e: KeyboardEvent) => {
     240            1 :                         e.preventDefault();
     241            1 :                         e.stopPropagation();
     242            1 :                         showPreviousRecord();
     243            1 :                     },
     244            1 :                     capture: true,
     245            1 :                 },
     246            1 :                 {
     247            1 :                     key: 'ArrowDown',
     248            1 :                     callback: (e: KeyboardEvent) => {
     249            1 :                         e.preventDefault();
     250            1 :                         e.stopPropagation();
     251            1 :                         showNextRecord();
     252            1 :                     },
     253            1 :                     capture: true,
     254            1 :                 },
     255            1 :             ]
     256            1 :             : []
     257            1 :     );
     258              : 
     259              :     // Scroll to the selected row when it changes
     260            1 :     React.useEffect(() => {
     261            1 :         if (jsonModalRecordIndex >= 0 && listApi) {
     262            1 :             listApi.scrollToRow({
     263            1 :                 index: jsonModalRecordIndex,
     264            1 :                 align: 'center',
     265            1 :             });
     266              :         }
     267            1 :     }, [jsonModalRecordIndex, listApi]);
     268              : 
     269              :     // Row props passed to the row component
     270            1 :     const rowProps = React.useMemo<LogRowProps>(() => ({
     271            1 :         logger,
     272            1 :         showJsonRecord,
     273            1 :         selectedRecordIndex: jsonModalRecordIndex,
     274            1 :         filteredLogs: props.traceId !== undefined ? filteredLogs : undefined,
     275            1 :     }), [logger, showJsonRecord, jsonModalRecordIndex, props.traceId, filteredLogs, filteredLogsVersion]);
     276              : 
     277              :     // Determine row count based on whether we're filtering by trace
     278            1 :     const rowCount = props.traceId !== undefined ? filteredLogs.length : logger.buffer.length;
     279              : 
     280            1 :     return (
     281            1 :         <>
     282            1 :             <div className={styles.overlay} data-electron-drag-region="false">
     283            1 :                 <div className={styles.header_container}>
     284            1 :                     <div className={styles.header_left_container}>
     285            1 :                         <div className={styles.title}>
     286            1 :                             {props.traceId !== undefined ? `Logs (Trace ${props.traceId})` : 'Logs'}
     287            1 :                         </div>
     288            1 :                     </div>
     289            1 :                     <div className={styles.header_right_container}>
     290            1 :                         <IconButton
     291            1 :                             variant={ButtonVariant.Invisible}
     292            1 :                             aria-label="Download Logs as JSON"
     293            1 :                             onClick={downloadLogs}
     294              :                         >
     295            1 :                             <DownloadIcon />
     296            1 :                         </IconButton>
     297            1 :                         <IconButton
     298            1 :                             ref={closeRef}
     299            1 :                             variant={ButtonVariant.Invisible}
     300            1 :                             aria-label="Close"
     301            1 :                             onClick={props.onClose}
     302              :                         >
     303            1 :                             <XIcon />
     304            1 :                         </IconButton>
     305            1 :                     </div>
     306            1 :                 </div>
     307            1 :                 <div className={styles.log_header_row}>
     308            1 :                     <div className={styles.log_header_main}>
     309            1 :                         <div className={styles.log_header_timestamp}>Time</div>
     310            1 :                         <div className={styles.log_header_level}>Level</div>
     311            1 :                         <div className={styles.log_header_message}>Message</div>
     312            1 :                     </div>
     313            1 :                 </div>
     314            1 :                 <div className={styles.log_grid_container} ref={containerRef}>
     315            1 :                     <List
     316            1 :                         listRef={setListApi}
     317            1 :                         style={{ width: containerWidth, height: containerHeight }}
     318            1 :                         rowCount={rowCount}
     319            1 :                         rowHeight={ROW_HEIGHT}
     320            1 :                         rowComponent={LogRow}
     321            1 :                         rowProps={rowProps}
     322            1 :                     />
     323            1 :                     <VerticalScrollShadows
     324            1 :                         getScrollElement={getScrollElement}
     325            1 :                         refreshKey={rowCount}
     326            1 :                     />
     327            1 :                 </div>
     328            1 :             </div>
     329            1 :             <LogJsonModal
     330            1 :                 record={jsonModalRecord}
     331            1 :                 recordIndex={jsonModalRecordIndex}
     332            1 :                 maxIndex={props.traceId !== undefined ? filteredLogs.length - 1 : logger.buffer.length - 1}
     333            1 :                 anchorRef={containerRef}
     334            1 :                 returnFocusRef={closeRef}
     335            1 :                 align={AnchorAlignment.Start}
     336            1 :                 side={AnchorSide.OutsideLeft}
     337            1 :                 onClose={closeJsonModal}
     338            1 :                 onPrevious={showPreviousRecord}
     339            1 :                 onNext={showNextRecord}
     340            1 :             />
     341            1 :         </>
     342              :     );
     343            1 : }
        

Generated by: LCOV version 2.0-1