From 5ce3248efdf1b9d68ce06435eb99d582ba930466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:44:35 +0000 Subject: [PATCH] Wave 5: LogViewer virtualized list and CrashReport component - Task #6: Create LogViewer with react-window virtualization (search with highlighting, auto-scroll toggle, timestamp format toggle) (shift+click range selection, level/category color badges) - Task #12: Create CrashReport frontend component (crash timeline SVG, pattern analysis with confidence bar) (auto-fix params display, retry API integration) Co-Authored-By: Claude Opus 4.5 --- web/components/JobDevTools/CrashReport.tsx | 459 ++++++++++++++++++++ web/components/JobDevTools/LogViewer.tsx | 464 +++++++++++++++++++++ 2 files changed, 923 insertions(+) create mode 100644 web/components/JobDevTools/CrashReport.tsx create mode 100644 web/components/JobDevTools/LogViewer.tsx diff --git a/web/components/JobDevTools/CrashReport.tsx b/web/components/JobDevTools/CrashReport.tsx new file mode 100644 index 0000000..bf2489d --- /dev/null +++ b/web/components/JobDevTools/CrashReport.tsx @@ -0,0 +1,459 @@ +'use client'; + +import { useState } from 'react'; +import { + AlertTriangle, + AlertOctagon, + Zap, + Clock, + TrendingUp, + ChevronDown, + ChevronUp, + RefreshCw, + Wrench, + Lightbulb, + Activity, + MemoryStick, + Boxes, +} from 'lucide-react'; +import type { StructuredLog } from './index'; + +// Crash analysis types +interface CrashAnalysis { + pattern: string; + confidence: number; + description: string; + suggested_fix: string; + auto_fix_params?: Record; +} + +interface MetricsHistoryEntry { + timestamp_ms: number; + memory_mb: number; + dom_nodes: number; +} + +export interface CrashReportData { + crash_id: string; + crash_type: string; + error_message: string; + analysis: CrashAnalysis; + metrics_history?: MetricsHistoryEntry[]; + logs_before_crash?: StructuredLog[]; + created_at: string; +} + +export interface CrashReportProps { + jobId: string; + crashReport: CrashReportData; + onRetry?: (applyFix: boolean) => void; +} + +// Pattern icons mapping +const PATTERN_ICONS: Record = { + memory_exhaustion: MemoryStick, + dom_explosion: Boxes, + timeout: Clock, + network_failure: Zap, + rate_limited: AlertOctagon, + default: AlertTriangle, +}; + +// Crash type severity colors +const CRASH_TYPE_COLORS: Record = { + memory_exhaustion: { bg: 'bg-red-900', border: 'border-red-600', text: 'text-red-200', icon: 'text-red-400' }, + dom_explosion: { bg: 'bg-orange-900', border: 'border-orange-600', text: 'text-orange-200', icon: 'text-orange-400' }, + timeout: { bg: 'bg-yellow-900', border: 'border-yellow-600', text: 'text-yellow-200', icon: 'text-yellow-400' }, + network_failure: { bg: 'bg-purple-900', border: 'border-purple-600', text: 'text-purple-200', icon: 'text-purple-400' }, + rate_limited: { bg: 'bg-blue-900', border: 'border-blue-600', text: 'text-blue-200', icon: 'text-blue-400' }, + default: { bg: 'bg-red-900', border: 'border-red-600', text: 'text-red-200', icon: 'text-red-400' }, +}; + +// Log level colors for the logs section +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', +}; + +export default function CrashReport({ jobId, crashReport, onRetry }: CrashReportProps) { + const [isLogsExpanded, setIsLogsExpanded] = useState(false); + const [isRetrying, setIsRetrying] = useState(false); + const [retryError, setRetryError] = useState(null); + + const { crash_type, error_message, analysis, metrics_history, logs_before_crash, created_at } = crashReport; + + // Get styling for crash type + const crashColors = CRASH_TYPE_COLORS[crash_type] || CRASH_TYPE_COLORS.default; + const PatternIcon = PATTERN_ICONS[analysis.pattern] || PATTERN_ICONS.default; + + // Format timestamp + const formatTimestamp = (timestamp: string) => { + try { + const date = new Date(timestamp); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } catch { + return timestamp; + } + }; + + const formatLogTimestamp = (timestamp: 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; + } + }; + + // Get confidence color + const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.8) return 'bg-green-600 text-green-100'; + if (confidence >= 0.5) return 'bg-yellow-600 text-yellow-100'; + return 'bg-orange-600 text-orange-100'; + }; + + // Mini timeline visualization + const renderMetricsTimeline = () => { + if (!metrics_history || metrics_history.length < 2) { + return ( +
+ No metrics history available +
+ ); + } + + const memoryValues = metrics_history.map((m) => m.memory_mb); + const maxMemory = Math.max(...memoryValues); + const minMemory = Math.min(...memoryValues); + const range = maxMemory - minMemory || 1; + + // Calculate points for SVG path + const width = 100; + const height = 40; + const points = metrics_history.map((entry, index) => { + const x = (index / (metrics_history.length - 1)) * width; + const y = height - ((entry.memory_mb - minMemory) / range) * height; + return `${x},${y}`; + }); + + const pathD = `M ${points.join(' L ')}`; + + return ( +
+
+ + + Memory Usage Over Time + + {maxMemory.toFixed(0)}MB peak +
+
+ + {/* Gradient fill under the line */} + + + + + + + {/* Fill area */} + + {/* Line */} + + {/* Crash point indicator */} + + + {/* Crash indicator */} +
+ CRASH +
+
+
+ {minMemory.toFixed(0)}MB + {metrics_history.length} data points +
+
+ ); + }; + + // Handle retry with/without fix + const handleRetry = async (applyFix: boolean) => { + setIsRetrying(true); + setRetryError(null); + + try { + const response = await fetch(`/api/jobs/${jobId}/retry?apply_fix=${applyFix}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || data.message || `Retry failed with status ${response.status}`); + } + + // Call the onRetry callback if provided + if (onRetry) { + onRetry(applyFix); + } + } catch (err) { + setRetryError(err instanceof Error ? err.message : 'Failed to retry job'); + } finally { + setIsRetrying(false); + } + }; + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

+ {crash_type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())} +

+

{error_message}

+
+
+
+
+ + {formatTimestamp(created_at)} +
+
+
+
+ + {/* Content */} +
+ {/* Timeline to Crash */} +
+

+ + Timeline to Crash +

+ {renderMetricsTimeline()} +
+ + {/* Pattern Analysis */} +
+

+ + Pattern Analysis +

+ +
+ {/* Pattern name with confidence */} +
+ + {analysis.pattern.replace(/_/g, ' ')} + + + {Math.round(analysis.confidence * 100)}% confidence + +
+ + {/* Confidence bar */} +
+
= 0.8 + ? 'bg-green-500' + : analysis.confidence >= 0.5 + ? 'bg-yellow-500' + : 'bg-orange-500' + }`} + style={{ width: `${analysis.confidence * 100}%` }} + /> +
+ + {/* Description */} +

{analysis.description}

+ + {/* Suggested fix callout */} +
+
+ +
+ Suggested Fix +

{analysis.suggested_fix}

+
+
+
+
+
+ + {/* Auto-Fix Options */} + {analysis.auto_fix_params && Object.keys(analysis.auto_fix_params).length > 0 && ( +
+

+ + Auto-Fix Parameters +

+
+ {Object.entries(analysis.auto_fix_params).map(([key, value]) => ( +
+ {key} + + {typeof value === 'object' ? JSON.stringify(value) : String(value)} + +
+ ))} +
+
+ )} + + {/* Retry buttons */} +
+ + +
+ + {/* Retry error message */} + {retryError && ( +
+ {retryError} +
+ )} + + {/* Logs Before Crash */} + {logs_before_crash && logs_before_crash.length > 0 && ( +
+ + + {isLogsExpanded && ( +
+
+ {logs_before_crash.slice(-20).map((log, index) => ( +
+
+ + {formatLogTimestamp(log.timestamp)} + + + {log.level} + + + {log.category} + + + {log.message} + +
+
+ ))} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/web/components/JobDevTools/LogViewer.tsx b/web/components/JobDevTools/LogViewer.tsx new file mode 100644 index 0000000..c7e879c --- /dev/null +++ b/web/components/JobDevTools/LogViewer.tsx @@ -0,0 +1,464 @@ +'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 };