140 lines
4.9 KiB
TypeScript
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>
|
|
);
|
|
}
|