'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; import { Copy, ChevronRight, ChevronDown, Check } from 'lucide-react'; import { StructuredLog } from './index'; import { copyToClipboard, formatLogForCopy } from '@/lib/copy-utils'; // Types type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL'; type LogCategory = 'scraper' | 'browser' | 'network' | 'system'; export interface LogEntryProps { log: StructuredLog; index: number; isSelected: boolean; searchQuery?: string; timestampFormat: 'relative' | 'absolute'; onSelect: (index: number, shiftKey: boolean) => void; onCopy: (log: StructuredLog) => void; } // Styling constants - matching the dark theme 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', }; /** * 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): string { const nowMs = Date.now(); 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 || !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} ); })} ); } /** * Checks if a log has expandable content (metrics or network data) */ function hasExpandableContent(log: StructuredLog): boolean { const hasMetrics = !!(log.metrics && Object.keys(log.metrics).length > 0); const hasNetwork = !!(log.network && Object.keys(log.network).length > 0); return hasMetrics || hasNetwork; } /** * LogEntry - Individual log row component with click-to-copy functionality * * Features: * - Click to select (with shift key support for range selection) * - Double-click or copy icon to copy single log entry * - Visual feedback on copy (brief green flash animation) * - Level badge with colors (DEBUG=gray, INFO=blue, WARN=yellow, ERROR=red, FATAL=purple) * - Category badge with colors (scraper=green, browser=orange, network=cyan, system=gray) * - Expandable metrics view for logs with metrics/network data * - Search query highlighting in message text */ export default function LogEntry({ log, index, isSelected, searchQuery = '', timestampFormat, onSelect, onCopy, }: LogEntryProps) { const [isExpanded, setIsExpanded] = useState(false); const [copyFeedback, setCopyFeedback] = useState(false); const [isHovered, setIsHovered] = useState(false); const feedbackTimeoutRef = useRef(null); // Cleanup timeout on unmount useEffect(() => { return () => { if (feedbackTimeoutRef.current) { clearTimeout(feedbackTimeoutRef.current); } }; }, []); const timestamp = timestampFormat === 'absolute' ? formatAbsoluteTime(log.timestamp) : formatRelativeTime(log.timestamp_ms); 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 isEven = index % 2 === 0; const expandable = hasExpandableContent(log); // Handle single click for selection const handleClick = useCallback( (e: React.MouseEvent) => { onSelect(index, e.shiftKey); }, [index, onSelect] ); // Perform copy operation with visual feedback const performCopy = useCallback(async () => { const formattedLog = formatLogForCopy(log); const success = await copyToClipboard(formattedLog); if (success) { // Show visual feedback setCopyFeedback(true); onCopy(log); // Clear feedback after animation if (feedbackTimeoutRef.current) { clearTimeout(feedbackTimeoutRef.current); } feedbackTimeoutRef.current = setTimeout(() => { setCopyFeedback(false); }, 1000); } }, [log, onCopy]); // Handle double-click for copy const handleDoubleClick = useCallback(async () => { await performCopy(); }, [performCopy]); // Handle copy icon click const handleCopyClick = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); // Prevent triggering row selection await performCopy(); }, [performCopy] ); // Handle expand toggle const handleExpandClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); // Prevent triggering row selection setIsExpanded((prev) => !prev); }, [] ); // Build row background class const getRowBgClass = () => { if (copyFeedback) { return 'bg-green-900/50 border-l-2 border-green-500'; } if (isSelected) { return 'bg-blue-900/50 border-l-2 border-blue-500'; } if (isHovered) { return 'bg-gray-700/50'; } return isEven ? 'bg-gray-900/30' : 'bg-gray-800/30'; }; return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {/* Main row */}
{/* Expand chevron (if expandable) */}
{expandable && ( )}
{/* Timestamp */} {timestamp} {/* Level badge */} {log.level} {/* Category badge */} {log.category} {/* Message */} {/* Copy button (visible on hover) */}
{/* Expanded content */} {isExpanded && expandable && (
{/* Full message (no truncation) */}
{/* Metrics */} {log.metrics && Object.keys(log.metrics).length > 0 && (
Metrics:
{Object.entries(log.metrics).map(([key, value]) => (
{key}: {typeof value === 'number' ? value.toLocaleString() : typeof value === 'object' ? JSON.stringify(value) : String(value)}
))}
)} {/* Network info */} {log.network && Object.keys(log.network).length > 0 && (
Network:
{log.network.status !== undefined && (
Status: = 200 && log.network.status < 300 ? 'text-green-400' : log.network.status >= 400 ? 'text-red-400' : 'text-yellow-400' }`} > {log.network.status}
)} {log.network.url && (
URL: {log.network.url}
)} {log.network.duration !== undefined && (
Duration: {log.network.duration}ms
)} {/* Render any other network properties */} {Object.entries(log.network) .filter(([key]) => !['status', 'url', 'duration'].includes(key)) .map(([key, value]) => (
{key}: {typeof value === 'object' ? JSON.stringify(value) : String(value)}
))}
)}
)}
); } // Export types for external use export type { LogLevel, LogCategory };