LCOV - code coverage report
Current view: top level - dashql-app/src/ui/logger - logger_toast.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 49.4 % 87 43
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as React from 'react';
       2              : import { createPortal } from 'react-dom';
       3              : 
       4              : import { useLogger } from '../../platform/logger/logger_provider.js';
       5              : import { LogBuffer, LogLevel, LogRecord } from '../../platform/logger/log_buffer.js';
       6              : import { classNames } from '../../utils/classnames.js';
       7              : 
       8              : import * as styles from './logger_toast.module.css';
       9              : import { SymbolIcon } from '../foundations/symbol_icon.js';
      10              : import { IconButton } from '../foundations/button.js';
      11              : 
      12            1 : const TOAST_DURATION_MS = 6000;
      13            1 : const TOAST_EXIT_DURATION_MS = 200;
      14            1 : const MAX_VISIBLE_TOASTS = 5;
      15              : 
      16              : interface ToastItem {
      17              :     id: number;
      18              :     record: LogRecord;
      19              :     exiting: boolean;
      20              : }
      21              : 
      22              : interface ToastProps {
      23              :     item: ToastItem;
      24              :     onDismiss: (id: number) => void;
      25              : }
      26              : 
      27            1 : export function shouldShowLoggerToast(record: LogRecord): boolean {
      28            1 :     return record.level === LogLevel.Error;
      29            1 : }
      30              : 
      31            0 : function Toast({ item, onDismiss }: ToastProps) {
      32            0 :     const CloseIcon = SymbolIcon("x_16");
      33            0 :     const ErrorIcon = SymbolIcon("alert_fill_16");
      34              : 
      35            0 :     return (
      36            0 :         <div
      37            0 :             className={classNames(styles.toast, styles.toast_error, {
      38            0 :                 [styles.toast_exiting]: item.exiting,
      39            0 :             })}
      40            0 :             role="alert"
      41            0 :             aria-live="assertive"
      42              :         >
      43            0 :             <div className={styles.toast_icon}>
      44            0 :                 <ErrorIcon />
      45            0 :             </div>
      46            0 :             <div className={styles.toast_content}>
      47            0 :                 <div className={styles.toast_message}>{item.record.message}</div>
      48            0 :                 <div className={styles.toast_keyvalues}>
      49            0 :                     {Object.entries(item.record.keyValues).map(([k, v], i) => (
      50            0 :                         <React.Fragment key={i}>
      51            0 :                             <span key={i * 2 + 0} className={styles.toast_kv_key}>{k}</span>
      52            0 :                             <span key={i * 2 + 1} className={styles.toast_kv_value}>{v}</span>
      53            0 :                         </React.Fragment>
      54            0 :                     ))}
      55            0 :                 </div>
      56            0 :             </div>
      57            0 :             <IconButton
      58            0 :                 className={styles.toast_close}
      59            0 :                 onClick={() => onDismiss(item.id)}
      60            0 :                 aria-label="Dismiss notification"
      61              :             >
      62            0 :                 <CloseIcon />
      63            0 :             </IconButton>
      64            0 :         </div>
      65              :     );
      66            0 : }
      67              : 
      68            1 : export function LoggerToast() {
      69            1 :     const logger = useLogger();
      70            1 :     const [toasts, setToasts] = React.useState<ToastItem[]>([]);
      71            1 :     const nextIdRef = React.useRef(0);
      72              : 
      73            1 :     const dismissToast = React.useCallback((id: number) => {
      74              :         // Start exit animation
      75            0 :         setToasts(prev => prev.map(t => (t.id === id ? { ...t, exiting: true } : t)));
      76              : 
      77              :         // Remove after animation completes
      78            0 :         setTimeout(() => {
      79            0 :             setToasts(prev => prev.filter(t => t.id !== id));
      80            0 :         }, TOAST_EXIT_DURATION_MS);
      81            1 :     }, []);
      82              : 
      83            1 :     React.useEffect(() => {
      84            1 :         const buffer = logger.buffer;
      85              :         // Track how many entries we've already seen
      86            1 :         let lastSeenLength = buffer.length;
      87              : 
      88            1 :         const observer = (buf: LogBuffer) => {
      89            1 :             const currentLength = buf.length;
      90              : 
      91              :             // Process all new entries since last observation
      92            1 :             for (let i = lastSeenLength; i < currentLength; i++) {
      93            1 :                 const record = buf.at(i);
      94            1 :                 if (!record) continue;
      95              : 
      96              :                 // Errors require user attention. Warnings remain available in the logs without
      97              :                 // interrupting the user's current workflow.
      98            1 :                 if (!shouldShowLoggerToast(record)) {
      99            1 :                     continue;
     100              :                 }
     101              : 
     102            1 :                 const id = nextIdRef.current++;
     103            1 :                 setToasts(prev => {
     104            0 :                     const newToasts = [...prev, { id, record, exiting: false }];
     105              :                     // Limit visible toasts
     106            0 :                     if (newToasts.length > MAX_VISIBLE_TOASTS) {
     107            0 :                         return newToasts.slice(-MAX_VISIBLE_TOASTS);
     108              :                     }
     109            0 :                     return newToasts;
     110            1 :                 });
     111              : 
     112              :                 // Auto-dismiss after duration
     113            1 :                 setTimeout(() => {
     114            0 :                     dismissToast(id);
     115            1 :                 }, TOAST_DURATION_MS);
     116            1 :             }
     117              : 
     118            1 :             lastSeenLength = currentLength;
     119            1 :         };
     120              : 
     121            1 :         buffer.subscribe(observer);
     122              : 
     123            1 :         return () => {
     124            0 :             buffer.observers.delete(observer);
     125            0 :         };
     126            1 :     }, [logger, dismissToast]);
     127              : 
     128            1 :     if (toasts.length === 0) {
     129            1 :         return null;
     130              :     }
     131              : 
     132            1 :     return createPortal(
     133            1 :         <div className={styles.toast_container}>
     134            1 :             {toasts.map(item => (
     135            0 :                 <Toast key={item.id} item={item} onDismiss={dismissToast} />
     136            1 :             ))}
     137            1 :         </div>,
     138            1 :         document.body
     139            1 :     );
     140            1 : }
        

Generated by: LCOV version 2.0-1