Files
whyrating-engine-legacy/web/components/JobDevTools/MetricsDashboard.tsx
Alejandro Gutiérrez 2637d982e0 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>
2026-01-24 12:37:56 +00:00

387 lines
14 KiB
TypeScript

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