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 <noreply@anthropic.com>
This commit is contained in:
464
web/components/JobDevTools/LogViewer.tsx
Normal file
464
web/components/JobDevTools/LogViewer.tsx
Normal file
@@ -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<string>;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export interface LogViewerProps {
|
||||
logs: StructuredLog[];
|
||||
filter: LogViewerFilter;
|
||||
onLogSelect?: (log: StructuredLog, index: number) => void;
|
||||
selectedIndices?: Set<number>;
|
||||
}
|
||||
|
||||
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<LogLevel, string> = {
|
||||
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<LogCategory, string> = {
|
||||
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<LogLevel, string> = {
|
||||
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 ? (
|
||||
<mark
|
||||
key={index}
|
||||
className="bg-yellow-500/40 text-yellow-100 rounded px-0.5"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={index}>{part}</span>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 h-full cursor-pointer hover:bg-gray-700/50 transition-colors ${rowBgClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Timestamp */}
|
||||
<span className="text-gray-500 text-xs font-mono whitespace-nowrap w-[80px] flex-shrink-0">
|
||||
{timestamp}
|
||||
</span>
|
||||
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-semibold rounded ${levelBadgeClass} whitespace-nowrap flex-shrink-0 w-[50px] text-center`}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
|
||||
{/* Category badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-medium rounded ${categoryBadgeClass} whitespace-nowrap flex-shrink-0 w-[60px] text-center`}
|
||||
>
|
||||
{log.category}
|
||||
</span>
|
||||
|
||||
{/* Message */}
|
||||
<span
|
||||
className={`flex-1 font-mono text-sm truncate ${messageColor}`}
|
||||
title={log.message}
|
||||
>
|
||||
<HighlightedText text={log.message} query={searchQuery} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Type for row data passed via rowProps (merged with component props)
|
||||
interface LogRowProps {
|
||||
filteredLogs: Array<{ log: StructuredLog; originalIndex: number }>;
|
||||
selectedIndices: Set<number>;
|
||||
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 (
|
||||
<div style={style}>
|
||||
<LogRowInner
|
||||
log={log}
|
||||
originalIndex={originalIndex}
|
||||
isSelected={isSelected}
|
||||
isEven={index % 2 === 0}
|
||||
timestampFormat={timestampFormat}
|
||||
nowMs={nowMs}
|
||||
searchQuery={searchQuery}
|
||||
onClick={(e) => handleLogClick(e, index, originalIndex, log)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number | null>(null);
|
||||
|
||||
const listRef = useRef<ListImperativeAPI>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col h-full bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-gray-800 border-b border-gray-700">
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={localSearchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log count */}
|
||||
<span className="text-sm text-gray-400 whitespace-nowrap">
|
||||
{filteredLogs.length} of {logs.length} logs
|
||||
</span>
|
||||
|
||||
{/* Timestamp format toggle */}
|
||||
<button
|
||||
onClick={() => setTimestampFormat((prev) => (prev === 'absolute' ? 'relative' : 'absolute'))}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
timestampFormat === 'relative'
|
||||
? 'bg-blue-600 text-blue-100'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title={`Show ${timestampFormat === 'absolute' ? 'relative' : 'absolute'} time`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{timestampFormat === 'absolute' ? 'Absolute' : 'Relative'}
|
||||
</button>
|
||||
|
||||
{/* Auto-scroll toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const newAutoScroll = !autoScroll;
|
||||
setAutoScroll(newAutoScroll);
|
||||
if (newAutoScroll) {
|
||||
handleScrollToBottom();
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
autoScroll
|
||||
? 'bg-green-600 text-green-100'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
title={autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
Auto-scroll
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Virtualized log list */}
|
||||
<div ref={containerRef} className="flex-1 min-h-[200px]" style={{ height: 300 }}>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="text-center">
|
||||
<Bug className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No logs match your filters</p>
|
||||
{localSearchQuery && (
|
||||
<p className="text-xs mt-1">
|
||||
Try adjusting your search query
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
listRef={listRef}
|
||||
defaultHeight={300}
|
||||
rowCount={filteredLogs.length}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
rowComponent={LogRow}
|
||||
rowProps={rowData}
|
||||
className="scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with selection info */}
|
||||
{selectedIndices.size > 0 && (
|
||||
<div className="px-3 py-2 bg-gray-800 border-t border-gray-700 text-xs text-gray-400">
|
||||
{selectedIndices.size} log{selectedIndices.size !== 1 ? 's' : ''} selected
|
||||
<span className="ml-2 text-gray-500">
|
||||
(Shift+click to select range)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export types for external use
|
||||
export type { LogLevel, LogCategory };
|
||||
Reference in New Issue
Block a user