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:
Alejandro Gutiérrez
2026-01-24 12:37:56 +00:00
parent 9515dd2d42
commit 2637d982e0
4 changed files with 1331 additions and 0 deletions

View File

@@ -0,0 +1,386 @@
'use client';
import { useMemo } from 'react';
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { Activity, TrendingUp, HardDrive, Scroll } from 'lucide-react';
/**
* Represents a single metrics sample collected during job execution
*/
export interface MetricsSample {
timestamp_ms: number;
reviews_extracted: number;
scroll_count: number;
memory_mb: number;
extraction_rate: number; // reviews per second
}
interface MetricsDashboardProps {
metricsHistory: MetricsSample[];
currentMetrics?: MetricsSample;
isStreaming: boolean;
}
/**
* Formats a timestamp (in ms) to a relative time string
* e.g., "0s", "30s", "1m", "1m 30s", etc.
*/
function formatRelativeTime(timestampMs: number, startMs: number): string {
const elapsedMs = timestampMs - startMs;
const totalSeconds = Math.floor(elapsedMs / 1000);
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (seconds === 0) {
return `${minutes}m`;
}
return `${minutes}m ${seconds}s`;
}
/**
* MetricsDashboard displays real-time metrics during job execution
* with charts for extraction rate, cumulative reviews, and memory usage.
*/
export default function MetricsDashboard({
metricsHistory,
currentMetrics,
isStreaming,
}: MetricsDashboardProps) {
// Determine the starting timestamp for relative time calculations
const startTimestamp = useMemo(() => {
if (metricsHistory.length > 0) {
return metricsHistory[0].timestamp_ms;
}
return currentMetrics?.timestamp_ms ?? Date.now();
}, [metricsHistory, currentMetrics]);
// Transform metrics history for charts with relative time labels
const chartData = useMemo(() => {
return metricsHistory.map((sample) => ({
...sample,
time: formatRelativeTime(sample.timestamp_ms, startTimestamp),
timeMs: sample.timestamp_ms - startTimestamp,
}));
}, [metricsHistory, startTimestamp]);
// Get the latest metrics (either current or last from history)
const latestMetrics = currentMetrics ?? metricsHistory[metricsHistory.length - 1];
// Memory warning threshold
const MEMORY_WARNING_MB = 1500;
// Check if memory is above warning threshold
const isMemoryWarning = latestMetrics && latestMetrics.memory_mb >= MEMORY_WARNING_MB;
// Custom tooltip style
const tooltipStyle = {
backgroundColor: '#1f2937',
border: '1px solid #374151',
borderRadius: '8px',
padding: '8px 12px',
};
return (
<div className="space-y-4">
{/* Header with Live Indicator */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-100">Real-Time Metrics</h3>
{isStreaming && (
<div className="flex items-center gap-2 px-3 py-1 bg-green-900/50 border border-green-700 rounded-full">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-sm font-medium text-green-400">Live</span>
</div>
)}
</div>
{/* Progress Summary - Current Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Total Reviews */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-xs mb-1">
<TrendingUp className="w-4 h-4" />
<span>Total Reviews</span>
</div>
<div className="text-2xl font-bold text-blue-400">
{latestMetrics?.reviews_extracted ?? 0}
</div>
</div>
{/* Scroll Count */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-xs mb-1">
<Scroll className="w-4 h-4" />
<span>Scrolls</span>
</div>
<div className="text-2xl font-bold text-purple-400">
{latestMetrics?.scroll_count ?? 0}
</div>
</div>
{/* Extraction Rate */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-xs mb-1">
<Activity className="w-4 h-4" />
<span>Rate (r/s)</span>
</div>
<div className="text-2xl font-bold text-green-400">
{latestMetrics?.extraction_rate?.toFixed(2) ?? '0.00'}
</div>
</div>
{/* Memory Usage */}
<div className={`bg-gray-800 rounded-lg p-4 border ${
isMemoryWarning ? 'border-red-500' : 'border-gray-700'
}`}>
<div className="flex items-center gap-2 text-gray-400 text-xs mb-1">
<HardDrive className={`w-4 h-4 ${isMemoryWarning ? 'text-red-400' : ''}`} />
<span>Memory (MB)</span>
</div>
<div className={`text-2xl font-bold ${
isMemoryWarning ? 'text-red-400' : 'text-yellow-400'
}`}>
{latestMetrics?.memory_mb?.toFixed(0) ?? '0'}
</div>
{isMemoryWarning && (
<div className="text-xs text-red-400 mt-1">Warning: High memory</div>
)}
</div>
</div>
{/* Charts Grid - 2x2 on desktop, stacked on mobile */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Extraction Rate Chart */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-3">
Extraction Rate (reviews/second)
</h4>
<div className="h-[200px]">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="time"
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
domain={[0, 'auto']}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af', marginBottom: '4px' }}
itemStyle={{ color: '#22c55e' }}
formatter={(value) => [`${(value as number)?.toFixed(2) ?? '0.00'} r/s`, 'Rate']}
labelFormatter={(label) => `Time: ${label}`}
/>
<Line
type="monotone"
dataKey="extraction_rate"
stroke="#22c55e"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: '#22c55e' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
No data yet
</div>
)}
</div>
</div>
{/* Cumulative Reviews Chart */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-3">
Cumulative Reviews Extracted
</h4>
<div className="h-[200px]">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="time"
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
domain={[0, 'auto']}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af', marginBottom: '4px' }}
itemStyle={{ color: '#3b82f6' }}
formatter={(value) => [`${value ?? 0} reviews`, 'Total']}
labelFormatter={(label) => `Time: ${label}`}
/>
<defs>
<linearGradient id="reviewsGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="reviews_extracted"
stroke="#3b82f6"
strokeWidth={2}
fill="url(#reviewsGradient)"
activeDot={{ r: 4, fill: '#3b82f6' }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
No data yet
</div>
)}
</div>
</div>
{/* Memory Usage Chart */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-3">
Memory Usage (MB)
<span className="ml-2 text-xs text-gray-500">Warning threshold: {MEMORY_WARNING_MB}MB</span>
</h4>
<div className="h-[200px]">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="time"
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
domain={[0, (dataMax: number) => Math.max(dataMax * 1.1, MEMORY_WARNING_MB * 1.2)]}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af', marginBottom: '4px' }}
itemStyle={{ color: '#eab308' }}
formatter={(value) => [`${(value as number)?.toFixed(0) ?? '0'} MB`, 'Memory']}
labelFormatter={(label) => `Time: ${label}`}
/>
<ReferenceLine
y={MEMORY_WARNING_MB}
stroke="#ef4444"
strokeDasharray="5 5"
label={{
value: 'Warning',
position: 'right',
fill: '#ef4444',
fontSize: 10,
}}
/>
<Line
type="monotone"
dataKey="memory_mb"
stroke="#eab308"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: '#eab308' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
No data yet
</div>
)}
</div>
</div>
{/* Scroll Count Chart */}
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<h4 className="text-sm font-medium text-gray-300 mb-3">
Scroll Progress
</h4>
<div className="h-[200px]">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="time"
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
/>
<YAxis
tick={{ fill: '#9ca3af', fontSize: 11 }}
tickLine={{ stroke: '#4b5563' }}
axisLine={{ stroke: '#4b5563' }}
domain={[0, 'auto']}
/>
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af', marginBottom: '4px' }}
itemStyle={{ color: '#a855f7' }}
formatter={(value) => [`${value ?? 0} scrolls`, 'Count']}
labelFormatter={(label) => `Time: ${label}`}
/>
<defs>
<linearGradient id="scrollsGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.4} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="scroll_count"
stroke="#a855f7"
strokeWidth={2}
fill="url(#scrollsGradient)"
activeDot={{ r: 4, fill: '#a855f7' }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
No data yet
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight, User, Globe, Monitor, Cpu, Shield, Check, X, AlertTriangle } from 'lucide-react';
export interface SessionFingerprint {
user_agent: string;
platform: string;
language: string;
languages: string[];
timezone: string;
screen: { width: number; height: number; colorDepth: number };
viewport: { width: number; height: number };
webgl_vendor: string;
webgl_renderer: string;
canvas_fingerprint: string;
hardware_concurrency: number;
device_memory: number;
bot_detection_tests: {
webdriver_hidden: boolean;
chrome_runtime: boolean;
permissions_query: boolean;
};
captured_at: string;
}
interface SessionPanelProps {
fingerprint: SessionFingerprint;
}
function BotTestIndicator({ passed, label }: { passed: boolean | null | undefined; label: string }) {
if (passed === null || passed === undefined) {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-900/30 border border-yellow-700/50 rounded-lg">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
<span className="text-yellow-300 text-sm font-medium">{label}</span>
<span className="ml-auto text-yellow-500 text-xs font-mono">UNKNOWN</span>
</div>
);
}
if (passed) {
return (
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/30 border border-green-700/50 rounded-lg">
<Check className="w-4 h-4 text-green-500" />
<span className="text-green-300 text-sm font-medium">{label}</span>
<span className="ml-auto text-green-500 text-xs font-mono">PASSED</span>
</div>
);
}
return (
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/30 border border-red-700/50 rounded-lg">
<X className="w-4 h-4 text-red-500" />
<span className="text-red-300 text-sm font-medium">{label}</span>
<span className="ml-auto text-red-500 text-xs font-mono">FAILED</span>
</div>
);
}
function SectionHeader({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-700">
<Icon className="w-4 h-4 text-blue-400" />
<h4 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">{title}</h4>
</div>
);
}
function DataRow({ label, value, mono = true }: { label: string; value: string | number; mono?: boolean }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-gray-500 uppercase tracking-wide">{label}</span>
<span className={`text-sm text-gray-200 ${mono ? 'font-mono' : ''} break-all`}>{value}</span>
</div>
);
}
export default function SessionPanel({ fingerprint }: SessionPanelProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Calculate overall bot detection status
const tests = fingerprint.bot_detection_tests;
const testResults = [tests.webdriver_hidden, tests.chrome_runtime, tests.permissions_query];
const passedCount = testResults.filter(t => t === true).length;
const failedCount = testResults.filter(t => t === false).length;
const unknownCount = testResults.filter(t => t === null || t === undefined).length;
const overallStatus = failedCount > 0 ? 'warning' : unknownCount > 0 ? 'partial' : 'success';
const statusColors = {
success: 'bg-green-900/30 border-green-700/50 text-green-400',
partial: 'bg-yellow-900/30 border-yellow-700/50 text-yellow-400',
warning: 'bg-red-900/30 border-red-700/50 text-red-400',
};
return (
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden">
{/* Collapsible Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-800 hover:bg-gray-750 transition-colors"
>
<div className="flex items-center gap-3">
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-400" />
) : (
<ChevronRight className="w-5 h-5 text-gray-400" />
)}
<Shield className="w-5 h-5 text-blue-400" />
<span className="text-sm font-semibold text-gray-200">What Google Saw</span>
</div>
<div className={`flex items-center gap-2 px-2.5 py-1 rounded-full text-xs font-semibold border ${statusColors[overallStatus]}`}>
{overallStatus === 'success' && <Check className="w-3 h-3" />}
{overallStatus === 'partial' && <AlertTriangle className="w-3 h-3" />}
{overallStatus === 'warning' && <X className="w-3 h-3" />}
<span>
{passedCount}/{testResults.length} Tests Passed
</span>
</div>
</button>
{/* Collapsible Content */}
{isExpanded && (
<div className="px-4 pb-4 border-t border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
{/* Identity Section */}
<div className="space-y-3">
<SectionHeader icon={User} title="Identity" />
<div className="space-y-3 bg-gray-900/50 rounded-lg p-3">
<DataRow label="User Agent" value={fingerprint.user_agent} />
<DataRow label="Platform" value={fingerprint.platform} />
<DataRow label="Primary Language" value={fingerprint.language} />
<DataRow label="Languages" value={fingerprint.languages.join(', ')} />
</div>
</div>
{/* Geolocation Section */}
<div className="space-y-3">
<SectionHeader icon={Globe} title="Geolocation" />
<div className="space-y-3 bg-gray-900/50 rounded-lg p-3">
<DataRow label="Timezone" value={fingerprint.timezone} />
<DataRow label="Captured At" value={fingerprint.captured_at} />
</div>
</div>
{/* Display Section */}
<div className="space-y-3">
<SectionHeader icon={Monitor} title="Display" />
<div className="space-y-3 bg-gray-900/50 rounded-lg p-3">
<DataRow
label="Screen Resolution"
value={`${fingerprint.screen.width} x ${fingerprint.screen.height}`}
/>
<DataRow
label="Viewport Size"
value={`${fingerprint.viewport.width} x ${fingerprint.viewport.height}`}
/>
<DataRow
label="Color Depth"
value={`${fingerprint.screen.colorDepth}-bit`}
/>
</div>
</div>
{/* Hardware Section */}
<div className="space-y-3">
<SectionHeader icon={Cpu} title="Hardware" />
<div className="space-y-3 bg-gray-900/50 rounded-lg p-3">
<DataRow label="WebGL Vendor" value={fingerprint.webgl_vendor} />
<DataRow label="WebGL Renderer" value={fingerprint.webgl_renderer} />
<DataRow label="CPU Cores" value={fingerprint.hardware_concurrency} />
<DataRow label="Device Memory" value={`${fingerprint.device_memory} GB`} />
<DataRow label="Canvas Fingerprint" value={fingerprint.canvas_fingerprint} />
</div>
</div>
</div>
{/* Bot Detection Section - Full Width */}
<div className="mt-6 space-y-3">
<SectionHeader icon={Shield} title="Bot Detection Tests" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<BotTestIndicator
passed={tests.webdriver_hidden}
label="WebDriver Hidden"
/>
<BotTestIndicator
passed={tests.chrome_runtime}
label="Chrome Runtime"
/>
<BotTestIndicator
passed={tests.permissions_query}
label="Permissions Query"
/>
</div>
<div className="mt-3 text-xs text-gray-500 bg-gray-900/30 rounded-lg p-3">
<p>
<span className="text-green-400 font-semibold">Green checkmark</span> = Test passed (bot detection evaded)
</p>
<p>
<span className="text-red-400 font-semibold">Red X</span> = Test failed (may have been detected as a bot)
</p>
<p>
<span className="text-yellow-400 font-semibold">Yellow warning</span> = Test result unknown
</p>
</div>
</div>
</div>
)}
</div>
);
}

View 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>
);
}