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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user