'use client'; import { useState, useMemo, useRef, useEffect, useCallback, CSSProperties } from 'react'; import { List, ListImperativeAPI } from 'react-window'; import { Search, ArrowDown, Clock, Bug } from 'lucide-react'; import { StructuredLog } from './index'; // Types export interface LogViewerFilter { category: 'all' | 'scraper' | 'browser' | 'network' | 'system'; levels: Set; searchQuery: string; } export interface LogViewerProps { logs: StructuredLog[]; filter: LogViewerFilter; onLogSelect?: (log: StructuredLog, index: number) => void; selectedIndices?: Set; } type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; type LogCategory = 'scraper' | 'browser' | 'network' | 'system'; // Styling constants - matching the dark theme from index.tsx const LEVEL_BADGE_COLORS: Record = { DEBUG: 'bg-gray-600 text-gray-200', INFO: 'bg-blue-600 text-blue-100', WARN: 'bg-yellow-600 text-yellow-100', ERROR: 'bg-red-600 text-red-100', FATAL: 'bg-purple-600 text-purple-100', }; const CATEGORY_BADGE_COLORS: Record = { scraper: 'bg-green-700 text-green-200', browser: 'bg-orange-700 text-orange-200', network: 'bg-cyan-700 text-cyan-200', system: 'bg-gray-600 text-gray-200', }; const LEVEL_ROW_COLORS: Record = { DEBUG: 'text-gray-300', INFO: 'text-blue-300', WARN: 'text-yellow-300', ERROR: 'text-red-300', FATAL: 'text-purple-300', }; // Row height for virtualization const ROW_HEIGHT = 36; /** * Formats a timestamp as absolute time (HH:MM:SS.mmm) */ function formatAbsoluteTime(timestamp: string): string { try { const date = new Date(timestamp); return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', fractionalSecondDigits: 3, }); } catch { return timestamp; } } /** * Formats a timestamp as relative time (e.g., "2s ago", "1m ago") */ function formatRelativeTime(timestampMs: number, nowMs: number): string { const diffMs = nowMs - timestampMs; const diffSeconds = Math.floor(diffMs / 1000); if (diffSeconds < 0) { return 'just now'; } if (diffSeconds < 60) { return `${diffSeconds}s ago`; } const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return `${diffMinutes}m ago`; } const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) { return `${diffHours}h ago`; } const diffDays = Math.floor(diffHours / 24); return `${diffDays}d ago`; } /** * Highlights matching text in a string */ function HighlightedText({ text, query }: { text: string; query: string }) { if (!query.trim()) { return <>{text}; } const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); const parts = text.split(regex); return ( <> {parts.map((part, index) => { const isMatch = part.toLowerCase() === query.toLowerCase(); return isMatch ? ( {part} ) : ( {part} ); })} ); } /** * Individual log row component for virtualized list */ interface LogRowInnerProps { log: StructuredLog; originalIndex: number; isSelected: boolean; isEven: boolean; timestampFormat: 'relative' | 'absolute'; nowMs: number; searchQuery: string; onClick: (e: React.MouseEvent) => void; } function LogRowInner({ log, isSelected, isEven, timestampFormat, nowMs, searchQuery, onClick, }: LogRowInnerProps) { const timestamp = timestampFormat === 'absolute' ? formatAbsoluteTime(log.timestamp) : formatRelativeTime(log.timestamp_ms, nowMs); const levelBadgeClass = LEVEL_BADGE_COLORS[log.level] || LEVEL_BADGE_COLORS.INFO; const categoryBadgeClass = CATEGORY_BADGE_COLORS[log.category] || CATEGORY_BADGE_COLORS.system; const messageColor = LEVEL_ROW_COLORS[log.level] || LEVEL_ROW_COLORS.INFO; const rowBgClass = isSelected ? 'bg-blue-900/50 border-l-2 border-blue-500' : isEven ? 'bg-gray-900/30' : 'bg-gray-800/30'; return (
{/* Timestamp */} {timestamp} {/* Level badge */} {log.level} {/* Category badge */} {log.category} {/* Message */}
); } // Type for row data passed via rowProps (merged with component props) interface LogRowProps { filteredLogs: Array<{ log: StructuredLog; originalIndex: number }>; selectedIndices: Set; timestampFormat: 'relative' | 'absolute'; nowMs: number; searchQuery: string; handleLogClick: ( e: React.MouseEvent, filteredIndex: number, originalIndex: number, log: StructuredLog ) => void; } // Row component for react-window v2 - receives index, style, ariaAttributes + rowProps function LogRow({ index, style, filteredLogs, selectedIndices, timestampFormat, nowMs, searchQuery, handleLogClick, }: LogRowProps & { index: number; style: CSSProperties; ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem'; }; }) { const item = filteredLogs[index]; if (!item) return null; const { log, originalIndex } = item; const isSelected = selectedIndices.has(originalIndex); return (
handleLogClick(e, index, originalIndex, log)} />
); } /** * LogViewer - A virtualized log viewer component using react-window * * Features: * - Virtualized list for performance with large log sets * - Search filtering with highlighted matches * - Auto-scroll toggle for following new logs * - Timestamp format toggle (relative vs absolute) * - Click to select, shift+click for range selection */ export default function LogViewer({ logs, filter, onLogSelect, selectedIndices = new Set(), }: LogViewerProps) { const [autoScroll, setAutoScroll] = useState(true); const [timestampFormat, setTimestampFormat] = useState<'relative' | 'absolute'>('absolute'); const [localSearchQuery, setLocalSearchQuery] = useState(filter.searchQuery); const [nowMs, setNowMs] = useState(Date.now()); const [lastSelectedIndex, setLastSelectedIndex] = useState(null); const listRef = useRef(null); const containerRef = useRef(null); // Update "now" for relative timestamps every second useEffect(() => { if (timestampFormat === 'relative') { const interval = setInterval(() => setNowMs(Date.now()), 1000); return () => clearInterval(interval); } }, [timestampFormat]); // Filter logs based on category, levels, and search query const filteredLogs = useMemo(() => { const searchLower = localSearchQuery.toLowerCase(); return logs .map((log, originalIndex) => ({ log, originalIndex })) .filter(({ log }) => { // Category filter if (filter.category !== 'all' && log.category !== filter.category) { return false; } // Level filter if (!filter.levels.has(log.level)) { return false; } // Search filter if (searchLower && !log.message.toLowerCase().includes(searchLower)) { return false; } return true; }); }, [logs, filter.category, filter.levels, localSearchQuery]); // Auto-scroll to bottom when new logs arrive useEffect(() => { if (autoScroll && listRef.current && filteredLogs.length > 0) { listRef.current.scrollToRow({ index: filteredLogs.length - 1, align: 'end' }); } }, [filteredLogs.length, autoScroll, listRef]); // Handle log selection with shift+click support const handleLogClick = useCallback( (e: React.MouseEvent, filteredIndex: number, originalIndex: number, log: StructuredLog) => { if (e.shiftKey && lastSelectedIndex !== null && onLogSelect) { // Range selection const start = Math.min(lastSelectedIndex, filteredIndex); const end = Math.max(lastSelectedIndex, filteredIndex); for (let i = start; i <= end; i++) { const item = filteredLogs[i]; if (item) { onLogSelect(item.log, item.originalIndex); } } } else { // Single selection onLogSelect?.(log, originalIndex); setLastSelectedIndex(filteredIndex); } }, [lastSelectedIndex, onLogSelect, filteredLogs] ); // Row data passed via rowProps const rowData: LogRowProps = useMemo( () => ({ filteredLogs, selectedIndices, timestampFormat, nowMs, searchQuery: localSearchQuery, handleLogClick, }), [filteredLogs, selectedIndices, timestampFormat, nowMs, localSearchQuery, handleLogClick] ); // Handle scroll for auto-scroll detection const handleScrollToBottom = useCallback(() => { if (listRef.current && filteredLogs.length > 0) { listRef.current.scrollToRow({ index: filteredLogs.length - 1, align: 'end' }); } }, [listRef, filteredLogs.length]); return (
{/* Toolbar */}
{/* Search input */}
setLocalSearchQuery(e.target.value)} className="w-full pl-9 pr-3 py-1.5 bg-gray-700 border border-gray-600 rounded-md text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" />
{/* Log count */} {filteredLogs.length} of {logs.length} logs {/* Timestamp format toggle */} {/* Auto-scroll toggle */}
{/* Virtualized log list */}
{filteredLogs.length === 0 ? (

No logs match your filters

{localSearchQuery && (

Try adjusting your search query

)}
) : ( )}
{/* Footer with selection info */} {selectedIndices.size > 0 && (
{selectedIndices.size} log{selectedIndices.size !== 1 ? 's' : ''} selected (Shift+click to select range)
)}
); } // Export types for external use export type { LogLevel, LogCategory };