← back to Omega Watches 2
src/app/(dashboard)/jobs/page.tsx
352 lines
"use client";
import { useEffect, useState, useMemo } from "react";
import { api } from "@/lib/api-client";
import { formatDateTime } from "@/lib/utils";
import { JobStatusChart } from "@/components/charts";
interface Job {
run_id: number;
source_id: string;
source_name: string;
source_display_name: string;
job_type: string;
status: string;
records_fetched: number;
records_parsed: number;
records_inserted: number;
records_updated: number;
records_quarantined: number;
records_deduplicated: number;
duration_ms: number | null;
started_at: string;
completed_at: string | null;
parser_version: string;
error_message: string | null;
}
const STATUS_TAB = ["all", "completed", "running", "pending", "failed"] as const;
const statusColor: Record<string, string> = {
completed: "badge-success",
running: "badge-info",
pending: "badge-warning",
failed: "badge-error",
cancelled: "badge-error",
};
export default function JobRunsPage() {
const [jobs, setJobs] = useState<Job[]>([]);
const [sources, setSources] = useState<any[]>([]);
const [dailyStats, setDailyStats] = useState<any[]>([]);
const [error, setError] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [sourceFilter, setSourceFilter] = useState("");
const [expanded, setExpanded] = useState<number | null>(null);
const [triggering, setTriggering] = useState(false);
const [triggerSource, setTriggerSource] = useState("");
const [triggerMsg, setTriggerMsg] = useState("");
useEffect(() => {
loadAll();
const interval = setInterval(() => loadJobs(), 15000);
return () => clearInterval(interval);
}, []);
async function loadAll() {
try {
const [j, s, ds] = await Promise.all([
api.getJobs({ limit: 200 }),
api.getSources(),
api.getJobsDailyStats().catch(() => []),
]);
setJobs(j);
setSources(s);
setDailyStats(ds);
} catch (e: any) {
setError(e.message);
}
}
async function loadJobs() {
try {
const j = await api.getJobs({ limit: 200 });
setJobs(j);
} catch {}
}
async function triggerRun() {
if (!triggerSource) return;
setTriggering(true);
setTriggerMsg("");
try {
const result = await api.runJob(triggerSource);
setTriggerMsg(`Job #${result.run_id} queued for ${triggerSource}`);
setTimeout(loadJobs, 1000);
} catch (e: any) {
setTriggerMsg(`Error: ${e.message}`);
} finally {
setTriggering(false);
}
}
// Summary stats
const stats = useMemo(() => {
const total = jobs.length;
const completed = jobs.filter((j) => j.status === "completed").length;
const failed = jobs.filter((j) => j.status === "failed").length;
const running = jobs.filter((j) => j.status === "running").length;
const totalFetched = jobs.reduce((a, j) => a + (j.records_fetched || 0), 0);
const totalInserted = jobs.reduce((a, j) => a + (j.records_inserted || 0), 0);
const totalQuarantined = jobs.reduce((a, j) => a + (j.records_quarantined || 0), 0);
const avgDuration = jobs.filter((j) => j.duration_ms).length > 0
? Math.round(jobs.filter((j) => j.duration_ms).reduce((a, j) => a + (j.duration_ms || 0), 0) / jobs.filter((j) => j.duration_ms).length)
: 0;
const successRate = total > 0 ? Math.round((completed / total) * 100) : 0;
return { total, completed, failed, running, totalFetched, totalInserted, totalQuarantined, avgDuration, successRate };
}, [jobs]);
// Filtered jobs
const filtered = useMemo(() => {
return jobs.filter((j) => {
if (statusFilter !== "all" && j.status !== statusFilter) return false;
if (sourceFilter && j.source_name !== sourceFilter) return false;
return true;
});
}, [jobs, statusFilter, sourceFilter]);
const uniqueSources = [...new Set(jobs.map((j) => j.source_name))].sort();
const activeSources = sources.filter((s) => s.is_active);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-100">Collector Job Runs</h1>
<div className="flex gap-2">
<button onClick={loadJobs} className="btn-secondary text-xs">
Refresh
</button>
</div>
</div>
{error && <div className="rounded bg-red-900/30 p-3 text-red-300">{error}</div>}
{/* Summary KPIs */}
<div className="grid gap-4 md:grid-cols-5">
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-gray-200">{stats.total}</div>
<div className="text-xs text-gray-500">Total Runs</div>
</div>
<div className="card border-navy-700 text-center">
<div className={`text-3xl font-bold ${stats.successRate >= 90 ? "text-green-400" : stats.successRate >= 70 ? "text-yellow-400" : "text-red-400"}`}>
{stats.successRate}%
</div>
<div className="text-xs text-gray-500">Success Rate</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-blue-400">{stats.totalFetched.toLocaleString()}</div>
<div className="text-xs text-gray-500">Records Fetched</div>
</div>
<div className="card border-navy-700 text-center">
<div className="text-3xl font-bold text-green-400">{stats.totalInserted.toLocaleString()}</div>
<div className="text-xs text-gray-500">Records Inserted</div>
</div>
<div className="card border-navy-700 text-center">
<div className={`text-3xl font-bold ${stats.totalQuarantined > 0 ? "text-yellow-400" : "text-gray-400"}`}>
{stats.totalQuarantined}
</div>
<div className="text-xs text-gray-500">Quarantined</div>
</div>
</div>
{/* Daily Job Status Chart */}
{dailyStats.length > 0 && (
<JobStatusChart data={dailyStats} title="Daily Job Status (30 Days)" />
)}
{/* Trigger Run Panel */}
<div className="card border-navy-700">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-400">
Trigger New Run
</h2>
<div className="flex items-center gap-3">
<select
className="input w-64"
value={triggerSource}
onChange={(e) => setTriggerSource(e.target.value)}
>
<option value="">Select source...</option>
{activeSources.map((s) => (
<option key={s.name} value={s.name}>
{s.display_name} ({s.access_method})
</option>
))}
</select>
<button
onClick={triggerRun}
disabled={!triggerSource || triggering}
className="btn-primary text-xs disabled:opacity-50"
>
{triggering ? "Queuing..." : "Run Incremental"}
</button>
{triggerMsg && (
<span className={`text-xs ${triggerMsg.startsWith("Error") ? "text-red-400" : "text-green-400"}`}>
{triggerMsg}
</span>
)}
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
{/* Status tabs */}
<div className="flex rounded-lg border border-navy-700 bg-navy-900 p-0.5">
{STATUS_TAB.map((tab) => (
<button
key={tab}
onClick={() => setStatusFilter(tab)}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
statusFilter === tab
? "bg-omega-500 text-white"
: "text-gray-400 hover:text-gray-200"
}`}
>
{tab === "all" ? "All" : tab.charAt(0).toUpperCase() + tab.slice(1)}
{tab !== "all" && (
<span className="ml-1 text-[10px] opacity-70">
({jobs.filter((j) => j.status === tab).length})
</span>
)}
</button>
))}
</div>
<select
className="input w-48"
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
<option value="">All Sources</option>
{uniqueSources.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<span className="text-xs text-gray-500">
Showing {filtered.length} of {jobs.length} runs
</span>
</div>
{/* Jobs Table */}
<div className="card border-navy-700 p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-navy-700 text-left text-xs uppercase text-gray-500">
<th className="p-3">Run</th>
<th className="p-3">Source</th>
<th className="p-3">Type</th>
<th className="p-3">Status</th>
<th className="p-3 text-right">Fetched</th>
<th className="p-3 text-right">Inserted</th>
<th className="p-3 text-right">Quarantined</th>
<th className="p-3 text-right">Duration</th>
<th className="p-3">Started</th>
<th className="p-3">Parser</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={10} className="p-6 text-center text-gray-500">
{jobs.length === 0
? "No job runs yet. Configure collectors and trigger a run."
: "No runs match the current filters."}
</td>
</tr>
) : (
filtered.map((j) => (
<>
<tr
key={j.run_id}
className={`cursor-pointer border-b border-navy-800 transition-colors hover:bg-navy-900 ${
expanded === j.run_id ? "bg-navy-900" : ""
}`}
onClick={() => setExpanded(expanded === j.run_id ? null : j.run_id)}
>
<td className="p-3 font-mono text-xs text-omega-400">#{j.run_id}</td>
<td className="p-3 font-medium text-gray-200">{j.source_display_name}</td>
<td className="p-3">
<span className={`badge ${j.job_type === "full" ? "badge-warning" : "badge-info"}`}>
{j.job_type}
</span>
</td>
<td className="p-3">
<span className={`badge ${statusColor[j.status] || "badge-info"}`}>
{j.status}
</span>
</td>
<td className="p-3 text-right text-gray-400">{j.records_fetched || 0}</td>
<td className="p-3 text-right text-gray-400">{j.records_inserted || 0}</td>
<td className="p-3 text-right">
<span className={j.records_quarantined > 0 ? "text-yellow-400 font-medium" : "text-gray-400"}>
{j.records_quarantined || 0}
</span>
</td>
<td className="p-3 text-right text-gray-400">
{j.duration_ms ? `${(j.duration_ms / 1000).toFixed(1)}s` : "-"}
</td>
<td className="p-3 text-xs text-gray-500">
{j.started_at ? formatDateTime(j.started_at) : "-"}
</td>
<td className="p-3 font-mono text-xs text-gray-500">{j.parser_version}</td>
</tr>
{expanded === j.run_id && (
<tr key={`${j.run_id}-detail`} className="border-b border-navy-800 bg-navy-900/50">
<td colSpan={10} className="p-4">
<div className="grid gap-4 md:grid-cols-4">
<div className="rounded bg-navy-900 p-3">
<div className="text-xs text-gray-500">Records Parsed</div>
<div className="mt-1 text-lg font-bold text-gray-200">{j.records_parsed || 0}</div>
</div>
<div className="rounded bg-navy-900 p-3">
<div className="text-xs text-gray-500">Records Updated</div>
<div className="mt-1 text-lg font-bold text-blue-400">{j.records_updated || 0}</div>
</div>
<div className="rounded bg-navy-900 p-3">
<div className="text-xs text-gray-500">Deduplicated</div>
<div className="mt-1 text-lg font-bold text-purple-400">{j.records_deduplicated || 0}</div>
</div>
<div className="rounded bg-navy-900 p-3">
<div className="text-xs text-gray-500">Completed At</div>
<div className="mt-1 text-sm text-gray-300">
{j.completed_at ? formatDateTime(j.completed_at) : "In progress..."}
</div>
</div>
</div>
{j.error_message && (
<div className="mt-3 rounded bg-red-900/20 p-3">
<div className="text-xs font-medium text-red-400">Error</div>
<pre className="mt-1 whitespace-pre-wrap text-xs text-red-300">{j.error_message}</pre>
</div>
)}
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
<span>Source ID: {j.source_id?.substring(0, 8)}...</span>
<span>Parser: {j.parser_version}</span>
{j.duration_ms && <span>Avg {((j.records_fetched || 0) / (j.duration_ms / 1000)).toFixed(0)} records/sec</span>}
</div>
</td>
</tr>
)}
</>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}