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:
459
web/components/JobDevTools/CrashReport.tsx
Normal file
459
web/components/JobDevTools/CrashReport.tsx
Normal file
@@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, typeof AlertTriangle> = {
|
||||||
|
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<string, { bg: string; border: string; text: string; icon: string }> = {
|
||||||
|
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<string, 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CrashReport({ jobId, crashReport, onRetry }: CrashReportProps) {
|
||||||
|
const [isLogsExpanded, setIsLogsExpanded] = useState(false);
|
||||||
|
const [isRetrying, setIsRetrying] = useState(false);
|
||||||
|
const [retryError, setRetryError] = useState<string | null>(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 (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-4">
|
||||||
|
No metrics history available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Activity className="w-3 h-3" />
|
||||||
|
Memory Usage Over Time
|
||||||
|
</span>
|
||||||
|
<span>{maxMemory.toFixed(0)}MB peak</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-12 bg-gray-800 rounded border border-gray-700 overflow-hidden">
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
{/* Gradient fill under the line */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="memoryGradient" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="rgb(239, 68, 68)" stopOpacity="0.4" />
|
||||||
|
<stop offset="100%" stopColor="rgb(239, 68, 68)" stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Fill area */}
|
||||||
|
<path
|
||||||
|
d={`${pathD} L ${width},${height} L 0,${height} Z`}
|
||||||
|
fill="url(#memoryGradient)"
|
||||||
|
/>
|
||||||
|
{/* Line */}
|
||||||
|
<path
|
||||||
|
d={pathD}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(239, 68, 68)"
|
||||||
|
strokeWidth="2"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/* Crash point indicator */}
|
||||||
|
<circle
|
||||||
|
cx={width}
|
||||||
|
cy={height - ((memoryValues[memoryValues.length - 1] - minMemory) / range) * height}
|
||||||
|
r="4"
|
||||||
|
fill="rgb(239, 68, 68)"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Crash indicator */}
|
||||||
|
<div className="absolute right-1 top-1 px-1.5 py-0.5 bg-red-600 text-white text-xs rounded font-medium">
|
||||||
|
CRASH
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>{minMemory.toFixed(0)}MB</span>
|
||||||
|
<span>{metrics_history.length} data points</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="w-full bg-gray-900 rounded-xl border-2 border-red-800 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`${crashColors.bg} ${crashColors.border} border-b-2 px-4 py-3`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-gray-900/50 ${crashColors.icon}`}>
|
||||||
|
<AlertOctagon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold text-lg ${crashColors.text}`}>
|
||||||
|
{crash_type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm">{error_message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-400 text-sm">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatTimestamp(created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Timeline to Crash */}
|
||||||
|
<div className="bg-gray-800 rounded-lg border border-gray-700 p-4">
|
||||||
|
<h4 className="text-gray-300 font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-gray-400" />
|
||||||
|
Timeline to Crash
|
||||||
|
</h4>
|
||||||
|
{renderMetricsTimeline()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pattern Analysis */}
|
||||||
|
<div className="bg-gray-800 rounded-lg border border-gray-700 p-4">
|
||||||
|
<h4 className="text-gray-300 font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<PatternIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
Pattern Analysis
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Pattern name with confidence */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-white font-medium">
|
||||||
|
{analysis.pattern.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs font-semibold rounded ${getConfidenceColor(analysis.confidence)}`}
|
||||||
|
>
|
||||||
|
{Math.round(analysis.confidence * 100)}% confidence
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confidence bar */}
|
||||||
|
<div className="w-full h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${
|
||||||
|
analysis.confidence >= 0.8
|
||||||
|
? 'bg-green-500'
|
||||||
|
: analysis.confidence >= 0.5
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-orange-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${analysis.confidence * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-400 text-sm">{analysis.description}</p>
|
||||||
|
|
||||||
|
{/* Suggested fix callout */}
|
||||||
|
<div className="bg-blue-900/30 border border-blue-700 rounded-lg p-3 mt-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Lightbulb className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-300 font-medium text-sm">Suggested Fix</span>
|
||||||
|
<p className="text-blue-200 text-sm mt-1">{analysis.suggested_fix}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-Fix Options */}
|
||||||
|
{analysis.auto_fix_params && Object.keys(analysis.auto_fix_params).length > 0 && (
|
||||||
|
<div className="bg-gray-800 rounded-lg border border-gray-700 p-4">
|
||||||
|
<h4 className="text-gray-300 font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Wrench className="w-4 h-4 text-gray-400" />
|
||||||
|
Auto-Fix Parameters
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(analysis.auto_fix_params).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between py-2 px-3 bg-gray-700/50 rounded"
|
||||||
|
>
|
||||||
|
<span className="text-gray-400 text-sm font-mono">{key}</span>
|
||||||
|
<span className="text-green-400 text-sm font-mono">
|
||||||
|
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Retry buttons */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetry(true)}
|
||||||
|
disabled={isRetrying || !analysis.auto_fix_params}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
|
||||||
|
isRetrying || !analysis.auto_fix_params
|
||||||
|
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-green-600 hover:bg-green-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Wrench className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Apply Fix & Retry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetry(false)}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${
|
||||||
|
isRetrying
|
||||||
|
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-gray-700 hover:bg-gray-600 text-gray-300 border border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Retry Without Fix
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retry error message */}
|
||||||
|
{retryError && (
|
||||||
|
<div className="bg-red-900/30 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
|
||||||
|
{retryError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Before Crash */}
|
||||||
|
{logs_before_crash && logs_before_crash.length > 0 && (
|
||||||
|
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLogsExpanded(!isLogsExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-750 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-gray-300 font-semibold flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-gray-400" />
|
||||||
|
Logs Before Crash
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-gray-700 text-gray-400 rounded-full">
|
||||||
|
{logs_before_crash.length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isLogsExpanded ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isLogsExpanded && (
|
||||||
|
<div className="border-t border-gray-700 max-h-[300px] overflow-y-auto">
|
||||||
|
<div className="font-mono text-sm">
|
||||||
|
{logs_before_crash.slice(-20).map((log, index) => (
|
||||||
|
<div
|
||||||
|
key={`${log.timestamp_ms}-${index}`}
|
||||||
|
className={`px-4 py-2 border-b border-gray-700/50 hover:bg-gray-700/30 ${
|
||||||
|
log.level === 'ERROR' || log.level === 'FATAL'
|
||||||
|
? 'bg-red-900/20'
|
||||||
|
: log.level === 'WARN'
|
||||||
|
? 'bg-yellow-900/10'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-gray-500 text-xs whitespace-nowrap pt-0.5">
|
||||||
|
{formatLogTimestamp(log.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.5 text-xs font-semibold rounded ${
|
||||||
|
LEVEL_BADGE_COLORS[log.level] || LEVEL_BADGE_COLORS.INFO
|
||||||
|
} whitespace-nowrap`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-xs font-medium rounded bg-gray-700 text-gray-300 whitespace-nowrap">
|
||||||
|
{log.category}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-gray-300 break-words">
|
||||||
|
{log.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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