Wave 4: JobDevTools UI components and crash report API
- Task #5: Create JobDevTools container component (tabs: All/Scraper/Browser/Network/System, level filters, count badges) - Task #11: Add crash report API endpoints (GET /jobs/{id}/crash-report, POST /jobs/{id}/retry?apply_fix=true, GET /crashes/stats) - Task #14: Create SessionPanel component (fingerprint display, bot detection indicators, collapsible sections) - Task #15: Create MetricsDashboard with recharts (extraction rate, cumulative reviews, memory usage, scroll progress) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
364
web/components/JobDevTools/index.tsx
Normal file
364
web/components/JobDevTools/index.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Bug, Globe, Network, Cpu, Filter, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
// Type definitions
|
||||
export interface StructuredLog {
|
||||
timestamp: string;
|
||||
timestamp_ms: number;
|
||||
level: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
||||
category: 'scraper' | 'browser' | 'network' | 'system';
|
||||
message: string;
|
||||
metrics?: Record<string, any>;
|
||||
network?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface MetricsData {
|
||||
cpu_percent?: number;
|
||||
memory_mb?: number;
|
||||
duration_ms?: number;
|
||||
requests_made?: number;
|
||||
reviews_scraped?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface CrashReport {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
stack_trace?: string;
|
||||
timestamp: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SessionFingerprint {
|
||||
session_id: string;
|
||||
browser_version?: string;
|
||||
proxy_used?: boolean;
|
||||
locale?: string;
|
||||
viewport?: { width: number; height: number };
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface JobDevToolsProps {
|
||||
logs: StructuredLog[];
|
||||
metrics?: MetricsData;
|
||||
crashReport?: CrashReport;
|
||||
sessionFingerprint?: SessionFingerprint;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'scraper' | 'browser' | 'network' | 'system';
|
||||
type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
||||
|
||||
const TAB_CONFIG: { id: TabType; label: string; icon: typeof Bug; category?: StructuredLog['category'] }[] = [
|
||||
{ id: 'all', label: 'All', icon: Filter },
|
||||
{ id: 'scraper', label: 'Scraper', icon: Bug, category: 'scraper' },
|
||||
{ id: 'browser', label: 'Browser', icon: Globe, category: 'browser' },
|
||||
{ id: 'network', label: 'Network', icon: Network, category: 'network' },
|
||||
{ id: 'system', label: 'System', icon: Cpu, category: 'system' },
|
||||
];
|
||||
|
||||
const LEVEL_COLORS: Record<LogLevel, { bg: string; text: string; border: string }> = {
|
||||
DEBUG: { bg: 'bg-gray-700', text: 'text-gray-300', border: 'border-gray-600' },
|
||||
INFO: { bg: 'bg-blue-900', text: 'text-blue-300', border: 'border-blue-700' },
|
||||
WARN: { bg: 'bg-yellow-900', text: 'text-yellow-300', border: 'border-yellow-700' },
|
||||
ERROR: { bg: 'bg-red-900', text: 'text-red-300', border: 'border-red-700' },
|
||||
FATAL: { bg: 'bg-purple-900', text: 'text-purple-300', border: 'border-purple-700' },
|
||||
};
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export default function JobDevTools({
|
||||
logs,
|
||||
metrics,
|
||||
crashReport,
|
||||
sessionFingerprint,
|
||||
isStreaming = false,
|
||||
}: JobDevToolsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
new Set(['DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL'])
|
||||
);
|
||||
const [showLevelFilter, setShowLevelFilter] = useState(false);
|
||||
|
||||
// Calculate counts per category
|
||||
const categoryCounts = useMemo(() => {
|
||||
const counts: Record<TabType, number> = {
|
||||
all: logs.length,
|
||||
scraper: 0,
|
||||
browser: 0,
|
||||
network: 0,
|
||||
system: 0,
|
||||
};
|
||||
|
||||
logs.forEach((log) => {
|
||||
if (log.category in counts) {
|
||||
counts[log.category as keyof typeof counts]++;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
// Calculate counts per level
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
FATAL: 0,
|
||||
};
|
||||
|
||||
logs.forEach((log) => {
|
||||
if (log.level in counts) {
|
||||
counts[log.level]++;
|
||||
}
|
||||
});
|
||||
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
// Filter logs by active tab and enabled levels
|
||||
const filteredLogs = useMemo(() => {
|
||||
return logs.filter((log) => {
|
||||
const matchesTab = activeTab === 'all' || log.category === activeTab;
|
||||
const matchesLevel = enabledLevels.has(log.level);
|
||||
return matchesTab && matchesLevel;
|
||||
});
|
||||
}, [logs, activeTab, enabledLevels]);
|
||||
|
||||
const toggleLevel = (level: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(level)) {
|
||||
// Don't allow deselecting all levels
|
||||
if (next.size > 1) {
|
||||
next.delete(level);
|
||||
}
|
||||
} else {
|
||||
next.add(level);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const formatTimestamp = (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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[400px] bg-gray-900 rounded-xl border-2 border-gray-700 flex flex-col">
|
||||
{/* Header with streaming indicator */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-700 bg-gray-800 rounded-t-xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bug className="w-5 h-5 text-green-400" />
|
||||
<span className="font-semibold text-gray-200">Job DevTools</span>
|
||||
{isStreaming && (
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-900 text-green-300 text-xs font-medium rounded-full border border-green-700">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
Streaming
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{filteredLogs.length} / {logs.length} logs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex items-center border-b border-gray-700 bg-gray-850 px-2">
|
||||
{TAB_CONFIG.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const count = categoryCounts[tab.id];
|
||||
const isActive = activeTab === tab.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all border-b-2 ${
|
||||
isActive
|
||||
? 'text-blue-400 border-blue-400 bg-gray-800'
|
||||
: 'text-gray-400 border-transparent hover:text-gray-200 hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
isActive
|
||||
? 'bg-blue-900 text-blue-300'
|
||||
: 'bg-gray-700 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Level filter toggle */}
|
||||
<div className="ml-auto relative">
|
||||
<button
|
||||
onClick={() => setShowLevelFilter(!showLevelFilter)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Levels</span>
|
||||
{showLevelFilter ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Level filter dropdown */}
|
||||
{showLevelFilter && (
|
||||
<div className="absolute right-0 top-full mt-1 z-10 bg-gray-800 border border-gray-600 rounded-lg shadow-lg p-2 min-w-[160px]">
|
||||
{(Object.keys(LEVEL_BADGE_COLORS) as LogLevel[]).map((level) => (
|
||||
<label
|
||||
key={level}
|
||||
className="flex items-center gap-2 px-2 py-1.5 hover:bg-gray-700 rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledLevels.has(level)}
|
||||
onChange={() => toggleLevel(level)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800"
|
||||
/>
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded ${LEVEL_BADGE_COLORS[level]}`}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs ml-auto">
|
||||
{levelCounts[level]}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log entries - scrollable area */}
|
||||
<div className="flex-1 overflow-y-auto min-h-[250px] max-h-[500px] font-mono text-sm">
|
||||
{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 to display</p>
|
||||
<p className="text-xs mt-1">
|
||||
{logs.length > 0
|
||||
? 'Try adjusting your filters'
|
||||
: 'Logs will appear here during job execution'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{filteredLogs.map((log, index) => {
|
||||
const levelStyle = LEVEL_COLORS[log.level];
|
||||
return (
|
||||
<div
|
||||
key={`${log.timestamp_ms}-${index}`}
|
||||
className={`px-4 py-2 hover:bg-gray-800 transition-colors ${levelStyle.bg} bg-opacity-20`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Timestamp */}
|
||||
<span className="text-gray-500 text-xs whitespace-nowrap pt-0.5">
|
||||
{formatTimestamp(log.timestamp)}
|
||||
</span>
|
||||
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-xs font-semibold rounded ${LEVEL_BADGE_COLORS[log.level]} whitespace-nowrap`}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
|
||||
{/* Category badge */}
|
||||
<span className="px-1.5 py-0.5 text-xs font-medium rounded bg-gray-700 text-gray-300 whitespace-nowrap">
|
||||
{log.category}
|
||||
</span>
|
||||
|
||||
{/* Message */}
|
||||
<span className={`flex-1 ${levelStyle.text} break-words`}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Additional data (metrics/network) */}
|
||||
{(log.metrics || log.network) && (
|
||||
<div className="mt-1 ml-[72px] text-xs text-gray-500">
|
||||
{log.metrics && (
|
||||
<span className="mr-4">
|
||||
metrics: {JSON.stringify(log.metrics)}
|
||||
</span>
|
||||
)}
|
||||
{log.network && (
|
||||
<span>network: {JSON.stringify(log.network)}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reserved space for metrics/session panels (footer) */}
|
||||
<div className="border-t border-gray-700 bg-gray-800 px-4 py-3 rounded-b-xl">
|
||||
<div className="flex items-center justify-between text-xs text-gray-400">
|
||||
<div className="flex items-center gap-4">
|
||||
{metrics && (
|
||||
<>
|
||||
{metrics.duration_ms !== undefined && (
|
||||
<span>Duration: {(metrics.duration_ms / 1000).toFixed(2)}s</span>
|
||||
)}
|
||||
{metrics.reviews_scraped !== undefined && (
|
||||
<span>Reviews: {metrics.reviews_scraped}</span>
|
||||
)}
|
||||
{metrics.memory_mb !== undefined && (
|
||||
<span>Memory: {metrics.memory_mb.toFixed(1)}MB</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{sessionFingerprint && (
|
||||
<span className="text-gray-500">
|
||||
Session: {sessionFingerprint.session_id?.slice(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
{crashReport && (
|
||||
<span className="text-red-400 font-medium">
|
||||
Crash: {crashReport.error_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user