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:
386
web/components/JobDevTools/MetricsDashboard.tsx
Normal file
386
web/components/JobDevTools/MetricsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user