- 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>
387 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|