fifteen/apps/web/app/admin/jobs/page.tsx
2026-03-01 21:58:53 +01:00

140 lines
4.9 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import type { JobSummary } from "@transportationer/shared";
const STATE_STYLES: Record<string, string> = {
active: "bg-yellow-100 text-yellow-800",
waiting: "bg-blue-100 text-blue-800",
"waiting-children": "bg-purple-100 text-purple-800",
completed: "bg-green-100 text-green-800",
failed: "bg-red-100 text-red-800",
delayed: "bg-gray-100 text-gray-600",
};
function formatDuration(ms: number | null): string {
if (ms === null) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60_000).toFixed(1)}m`;
}
export default function JobsPage() {
const [jobs, setJobs] = useState<JobSummary[]>([]);
const [loading, setLoading] = useState(true);
const refresh = () => {
fetch("/api/admin/jobs")
.then((r) => r.json())
.then(setJobs)
.finally(() => setLoading(false));
};
useEffect(() => {
refresh();
const id = setInterval(refresh, 5000);
return () => clearInterval(id);
}, []);
const handleDelete = async (jobId: string) => {
await fetch(`/api/admin/jobs/${jobId}`, { method: "DELETE" });
refresh();
};
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Job Queue</h1>
<button onClick={refresh} className="btn-secondary text-sm">
Refresh
</button>
</div>
{loading ? (
<div className="text-gray-500">Loading</div>
) : jobs.length === 0 ? (
<div className="card text-center py-12 text-gray-500">
No jobs in the queue.
</div>
) : (
<div className="card p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
{["ID", "Type", "City", "State", "Progress", "Duration", "Created", "Actions"].map(
(h) => (
<th
key={h}
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide"
>
{h}
</th>
),
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{jobs.map((job) => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{job.id.slice(0, 8)}
</td>
<td className="px-4 py-3 font-medium">{job.type}</td>
<td className="px-4 py-3 text-gray-600">{job.citySlug}</td>
<td className="px-4 py-3">
<span
className={`badge ${STATE_STYLES[job.state] ?? "bg-gray-100 text-gray-600"}`}
>
{job.state}
</span>
</td>
<td className="px-4 py-3">
{job.progress ? (
<div className="flex items-center gap-2">
<div className="w-20 bg-gray-200 rounded-full h-1.5">
<div
className="bg-brand-600 h-1.5 rounded-full"
style={{ width: `${job.progress.pct}%` }}
/>
</div>
<span className="text-xs text-gray-500">
{job.progress.pct}%
</span>
</div>
) : (
"—"
)}
</td>
<td className="px-4 py-3 text-gray-500">
{formatDuration(job.duration)}
</td>
<td className="px-4 py-3 text-gray-500">
{new Date(job.createdAt).toLocaleTimeString()}
</td>
<td className="px-4 py-3">
{job.state !== "active" && (
<button
onClick={() => handleDelete(job.id)}
className="text-xs text-red-500 hover:underline"
>
Remove
</button>
)}
{job.failedReason && (
<p
className="text-xs text-red-500 mt-1 max-w-xs truncate"
title={job.failedReason}
>
{job.failedReason}
</p>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}