Files
asf/frontend/asf-web/app/jobs/page.tsx
T
2026-09-08 07:21:34 -04:00

623 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Workflow,
Search,
Clock,
CheckCircle2,
XCircle,
Loader2,
CirclePause,
Circle,
PlayCircle,
Activity,
ChevronRight,
Send,
Tag,
Hash,
} from "lucide-react";
import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Badge } from "@/components/ui/Badge";
import { EmptyState } from "@/components/ui/EmptyState";
import { jobs, projects } from "@/lib/api";
import { cn, fmtDate, shortId } from "@/lib/utils";
import type { Job, JobCreate, JobStatus, JobType } from "@/lib/types";
import { JOB_STATUSES, JOB_TYPES } from "@/lib/types";
const DEMO_JOBS: Job[] = [
{
id: "j-demo-1",
tenant_id: "t1",
project_id: "p1",
type: "ai_interactive",
priority: "normal",
status: "completed",
payload: { prompt: "Summarize the spec.", temperature: 0.2 },
correlation_id: "cid-summ-001",
requested_by: "u1",
worker: "asf-worker-3",
model: "asf-mock-1",
result: { summary: "Mock AI summary for demo purposes." },
usage: { input_tokens: 112, output_tokens: 48, cpu_seconds: 0.02 },
progress_percent: 100,
created_at: "2025-11-15T09:00:00Z",
started_at: "2025-11-15T09:00:01Z",
completed_at: "2025-11-15T09:00:06Z",
},
{
id: "j-demo-2",
tenant_id: "t1",
project_id: "p1",
type: "testing",
priority: "high",
status: "running",
payload: { suite: "smoke", pattern: "**/test_*.py" },
requested_by: "u2",
worker: "asf-worker-1",
progress_percent: 42,
created_at: "2025-11-15T09:18:00Z",
started_at: "2025-11-15T09:18:10Z",
},
{
id: "j-demo-3",
tenant_id: "t1",
project_id: "p2",
type: "code_analysis",
priority: "normal",
status: "failed",
payload: { repository: "asf/core", branch: "main" },
requested_by: "u1",
worker: "asf-worker-2",
error: "Repository clone failed",
error_detail: { exit_code: 128, stderr: "fatal: repository not found" },
created_at: "2025-11-15T08:55:00Z",
started_at: "2025-11-15T08:55:15Z",
completed_at: "2025-11-15T08:56:30Z",
},
];
const STATUS_ICON: Record<JobStatus, React.ComponentType<{ className?: string }>> = {
queued: Clock,
running: PlayCircle,
completed: CheckCircle2,
failed: XCircle,
cancelled: CirclePause,
};
const STATUS_TONE: Record<JobStatus, "blue" | "amber" | "green" | "red" | "neutral" | "brand" | "violet"> = {
queued: "neutral",
running: "blue",
completed: "green",
failed: "red",
cancelled: "amber",
};
const TYPE_TONE: Record<string, "brand" | "violet" | "blue" | "green" | "amber" | "neutral" | "red"> = {
ai_interactive: "violet",
ai_background: "violet",
indexing: "blue",
code_analysis: "brand",
testing: "green",
devops: "amber",
notification: "neutral",
custom: "red",
};
function queueForJobType(t: JobType | string): string {
// 1-to-1 mapping with asf.jobs DIRECT exchange queues
switch (t) {
case "ai_interactive": return "asf.ai.interactive";
case "ai_background": return "asf.ai.background";
case "indexing": return "asf.indexing";
case "code_analysis": return "asf.code_analysis";
case "testing": return "asf.testing";
case "devops": return "asf.devops";
case "notification": return "asf.notifications";
default: return `asf.${t ?? "custom"}`;
}
}
export default function JobsPage() {
const queryClient = useQueryClient();
const [q, setQ] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create modal state
const [showNew, setShowNew] = useState(false);
const [newType, setNewType] = useState<JobType>("testing");
const [newProjectId, setNewProjectId] = useState<string>("");
const [newPriority, setNewPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
const [newSequence, setNewSequence] = useState<string>("hello-asf");
const projectsQ = useQuery({
queryKey: ["jobs-projects"],
queryFn: () => projects.list({ limit: 100 }),
retry: false,
refetchOnWindowFocus: false,
});
const listQ = useQuery({
queryKey: ["jobs-list", { status: statusFilter, type: typeFilter, q: q.trim() }],
queryFn: () =>
jobs.list({
status: statusFilter !== "all" ? (statusFilter as JobStatus) : undefined,
type: typeFilter !== "all" ? (typeFilter as JobType) : undefined,
limit: 200,
}),
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 3000, // auto-refresh each 3s so running → completed appears without manual reload
});
const submitM = useMutation({
mutationFn: async (body: JobCreate) => jobs.submit(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["jobs-list"] }).catch(() => undefined);
setShowNew(false);
},
});
const apiResolved = listQ.isFetched && listQ.data !== undefined;
const rows: Job[] = apiResolved ? listQ.data! : DEMO_JOBS;
const isMock = !apiResolved;
const projectNameById = useMemo(() => {
const m = new Map<string, string>();
for (const p of projectsQ.data ?? []) {
if (p?.id) m.set(String(p.id), p.name ?? shortId(String(p.id)));
}
return m;
}, [projectsQ.data]);
// Default first project for new job dropdown (if any)
React.useEffect(() => {
if (!newProjectId && projectsQ.data?.length) setNewProjectId(String(projectsQ.data[0].id));
}, [newProjectId, projectsQ.data]);
const filtered = useMemo(() => {
const ql = q.trim().toLowerCase();
return rows.filter((j) => {
if (statusFilter !== "all" && j.status !== statusFilter) return false;
if (typeFilter !== "all" && j.type !== typeFilter) return false;
if (ql) {
const hay = [
String(j.id),
String(j.correlation_id ?? ""),
String(j.worker ?? ""),
String(j.model ?? ""),
String(j.error ?? ""),
JSON.stringify(j.payload ?? {}),
JSON.stringify(j.result ?? {}),
]
.join(" ")
.toLowerCase();
if (!hay.includes(ql)) return false;
}
return true;
});
}, [rows, statusFilter, typeFilter, q]);
const selected =
(selectedId !== null ? filtered.find((j) => String(j.id) === String(selectedId)) : null) ??
filtered[0] ??
null;
const statuses = Array.from(new Set(rows.map((j) => j.status)));
return (
<div className="h-full flex flex-col min-h-0">
<header className="px-6 lg:px-8 pt-6 pb-4 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900 flex items-center gap-2">
<Workflow className="w-6 h-6 text-brand-600" /> Jobs
</h1>
<p className="mt-1 text-sm text-slate-500">
Asynchronous ASF work: queued → running → completed / failed. Each job is routed to its dedicated RabbitMQ queue.
</p>
</div>
<div className="flex items-center gap-2">
{isMock && <Badge tone="amber">Demo data</Badge>}
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue job
</Button>
</div>
</header>
{/* Stats strip */}
<section className="px-6 lg:px-8 pb-3 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2 text-sm">
{(["queued", "running", "completed", "failed", "cancelled"] as JobStatus[]).map((s) => {
const count = rows.filter((r) => r.status === s).length;
const Icon = STATUS_ICON[s];
return (
<button
key={s}
onClick={() => setStatusFilter(statusFilter === s ? "all" : s)}
className={cn(
"text-left p-2 rounded-lg border transition-colors",
statusFilter === s
? "border-brand-300 bg-brand-50"
: "border-slate-200 bg-white hover:bg-slate-50",
)}
>
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Icon className="w-3.5 h-3.5" />{s}</span>
<Badge tone={STATUS_TONE[s]}><span className="tabular-nums">{count}</span></Badge>
</div>
</button>
);
})}
<div className="p-2 rounded-lg border border-slate-200 bg-white">
<div className="flex items-center justify-between text-slate-500 text-xs">
<span className="inline-flex items-center gap-1"><Hash className="w-3.5 h-3.5" />Total</span>
<Badge tone="brand"><span className="tabular-nums">{rows.length}</span></Badge>
</div>
</div>
</section>
<div className="px-6 lg:px-8 pb-4 flex flex-wrap gap-2 items-center">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
className="asf-input pl-9"
placeholder="Search id, correlation_id, worker, payload json, error…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">All statuses</option>
{JOB_STATUSES.concat(statuses).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
className="asf-input !py-1.5 !text-xs !w-auto"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">All types</option>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
</div>
<div className="flex-1 min-h-0 px-6 lg:px-8 pb-6 grid grid-cols-1 xl:grid-cols-5 gap-4">
<Card className="xl:col-span-3 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Job list</CardTitle>
<span className="text-xs text-slate-500">Auto-refresh each 3s · click a row →</span>
</CardHeader>
<CardBody className="p-0 flex-1 min-h-0 overflow-auto">
{filtered.length === 0 ? (
<div className="p-6">
<EmptyState
title="No jobs match"
description="Try adjusting filters, or click Enqueue job to create a new echo testing job."
/>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-slate-50/90 backdrop-blur text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-2">ID</th>
<th className="text-left px-4 py-2">Type</th>
<th className="text-left px-4 py-2">Status</th>
<th className="text-left px-4 py-2">Progress</th>
<th className="text-left px-4 py-2">Queue</th>
<th className="text-left px-4 py-2">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((j) => {
const active = selected && String(selected.id) === String(j.id);
const StatusIcon = STATUS_ICON[j.status] ?? Circle;
return (
<tr
key={String(j.id)}
onClick={() => setSelectedId(String(j.id))}
className={cn(
"cursor-pointer hover:bg-brand-50/40 transition-colors",
active && "bg-brand-50/70",
)}
>
<td className="px-4 py-2.5 asf-monospace text-xs text-slate-500 align-top">
{shortId(String(j.id))}
</td>
<td className="px-4 py-2.5 align-top">
<Badge tone={TYPE_TONE[j.type] ?? "neutral"}>{j.type}</Badge>
</td>
<td className="px-4 py-2.5 align-top">
<span className="inline-flex items-center gap-1.5">
<StatusIcon className={cn(
"w-4 h-4",
j.status === "running" && "animate-spin text-brand-600",
j.status === "completed" && "text-emerald-600",
j.status === "failed" && "text-rose-600",
j.status === "queued" && "text-slate-500",
)} />
<Badge tone={STATUS_TONE[j.status]}>{j.status}</Badge>
</span>
</td>
<td className="px-4 py-2.5 align-top">
<div className="flex items-center gap-2 min-w-[100px]">
<div className="h-1.5 flex-1 rounded-full bg-slate-100 overflow-hidden">
<div
className={cn(
"h-full transition-all",
j.status === "failed" ? "bg-rose-500" : j.status === "completed" ? "bg-emerald-500" : "bg-brand-500",
)}
style={{ width: `${j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "running" ? 35 : 0)}%` }}
/>
</div>
<span className="text-xs tabular-nums text-slate-500 w-9 text-right">
{j.progress_percent ?? (j.status === "completed" ? 100 : j.status === "queued" ? 0 : "—")}%
</span>
</div>
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 asf-monospace whitespace-nowrap">
{queueForJobType(j.type)}
</td>
<td className="px-4 py-2.5 align-top text-xs text-slate-500 whitespace-nowrap">
{fmtDate(j.completed_at ?? j.started_at ?? j.created_at)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</CardBody>
</Card>
<Card className="xl:col-span-2 flex flex-col min-h-0">
<CardHeader>
<CardTitle>Detail</CardTitle>
<Link href="/ai-agents" className="text-xs text-brand-600 hover:underline inline-flex items-center gap-1">
AI Agents <ChevronRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardBody className="overflow-auto flex-1 min-h-0">
{selected ? (
<div className="space-y-4 text-sm">
<div>
<div className="flex items-center gap-2 flex-wrap mb-1.5">
<span className="asf-monospace text-xs text-slate-500">{shortId(String(selected.id))}</span>
<Badge tone={TYPE_TONE[selected.type] ?? "neutral"}>{selected.type}</Badge>
<Badge tone={STATUS_TONE[selected.status]}>{selected.status}</Badge>
{selected.priority && <Badge tone="neutral">{selected.priority}</Badge>}
</div>
<h2 className="text-lg font-semibold text-slate-900 leading-tight">
{selected.type} job · {queueForJobType(selected.type)}
</h2>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="text-xs text-slate-500">Project</dt>
<dd className="font-medium text-slate-800">
{selected.project_id
? (projectNameById.get(String(selected.project_id)) ?? shortId(String(selected.project_id)))
: "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Queue</dt>
<dd className="asf-monospace text-slate-700">{queueForJobType(selected.type)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Worker</dt>
<dd className="text-slate-700">{selected.worker ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Model</dt>
<dd className="text-slate-700">{selected.model ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Requested by</dt>
<dd className="asf-monospace text-slate-700">
{selected.requested_by ? shortId(String(selected.requested_by)) : "—"}
</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Correlation</dt>
<dd className="asf-monospace text-slate-700">{selected.correlation_id ?? "—"}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Created</dt>
<dd className="text-slate-700">{fmtDate(selected.created_at)}</dd>
</div>
<div>
<dt className="text-xs text-slate-500">Completed</dt>
<dd className="text-slate-700">{selected.completed_at ? fmtDate(selected.completed_at) : "—"}</dd>
</div>
</dl>
{selected.payload !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><Tag className="w-3 h-3" /> payload</div>
<pre className="p-3 rounded-lg bg-slate-50 border border-slate-100 text-xs overflow-auto max-h-40">
{JSON.stringify(selected.payload, null, 2)}
</pre>
</div>
)}
{selected.result !== undefined && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><CheckCircle2 className="w-3 h-3 text-emerald-600" /> result</div>
<pre className="p-3 rounded-lg bg-emerald-50 border border-emerald-100 text-xs overflow-auto max-h-48">
{JSON.stringify(selected.result, null, 2)}
</pre>
</div>
)}
{selected.error && (
<div>
<div className="text-xs text-slate-500 mb-1.5 flex items-center gap-1"><XCircle className="w-3 h-3 text-rose-600" /> error</div>
<div className="p-3 rounded-lg bg-rose-50 border border-rose-100 text-rose-700 text-xs overflow-auto max-h-40 space-y-2">
<div className="font-semibold">{selected.error}</div>
{selected.error_detail && (
<pre className="whitespace-pre-wrap break-all">{JSON.stringify(selected.error_detail, null, 2)}</pre>
)}
</div>
</div>
)}
{selected.usage && (
<div>
<div className="text-xs text-slate-500 mb-1.5">usage</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(selected.usage).map(([k, v]) =>
v === undefined ? null : (
<div key={k} className="flex justify-between p-2 rounded bg-slate-50 border border-slate-100">
<span className="text-slate-500">{k}</span>
<span className="font-medium text-slate-800 tabular-nums">{JSON.stringify(v)}</span>
</div>
),
)}
</div>
</div>
)}
<div className="pt-3 border-t border-slate-100 flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ["jobs-list"] })}
>
<Activity className="w-4 h-4" /> Refresh status
</Button>
<Button variant="primary" size="sm" onClick={() => setShowNew(true)}>
<Send className="w-4 h-4" /> Enqueue similar
</Button>
</div>
</div>
) : (
<EmptyState
title="Select a job"
description="Pick any row from the list to inspect payload, result, error and usage counters."
/>
)}
</CardBody>
</Card>
</div>
{/* New job modal */}
{showNew && (
<div className="fixed inset-0 z-50 grid place-items-center bg-slate-900/50 backdrop-blur-sm p-4" onClick={() => setShowNew(false)}>
<div
className="w-full max-w-lg rounded-xl bg-white border border-slate-200 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Send className="w-4 h-4 text-brand-600" /> Enqueue new job
</h3>
<button onClick={() => setShowNew(false)} className="text-slate-400 hover:text-slate-600 text-xl leading-none" aria-label="close">×</button>
</div>
<form
className="p-5 space-y-3"
onSubmit={(e) => {
e.preventDefault();
submitM.mutate({
project_id: newProjectId || undefined,
type: newType,
priority: newPriority,
payload: { sequence: newSequence, message: "hello-asf", ts: Date.now() },
});
}}
>
<div>
<label className="text-xs text-slate-500">Type (direct queue)</label>
<select
className="asf-input mt-1"
value={newType}
onChange={(e) => setNewType(e.target.value as JobType)}
>
{JOB_TYPES.map((t) => (
<option key={t} value={t}>{t} · {queueForJobType(t)}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Project</label>
<select
className="asf-input mt-1"
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
>
<option value="">(no project)</option>
{(projectsQ.data ?? []).map((p) => (
<option key={String(p.id)} value={String(p.id)}>{p.name ?? shortId(String(p.id))}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Priority</label>
<select
className="asf-input mt-1"
value={newPriority}
onChange={(e) => setNewPriority(e.target.value as typeof newPriority)}
>
{(["low", "normal", "high", "urgent"] as const).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-slate-500">Echo sequence (payload.sequence)</label>
<input
className="asf-input mt-1"
value={newSequence}
onChange={(e) => setNewSequence(e.target.value)}
placeholder="job-e2e-1"
/>
</div>
<div className="pt-3 border-t border-slate-100 flex justify-end gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => setShowNew(false)}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
size="sm"
disabled={submitM.isPending || !newType}
>
{submitM.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" /> Enqueuing…
</>
) : (
<>
<Send className="w-4 h-4" /> Enqueue
</>
)}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
}