This commit is contained in:
valenti
2026-09-08 07:21:34 -04:00
commit a4b224da8e
169 changed files with 27483 additions and 0 deletions
+622
View File
@@ -0,0 +1,622 @@
"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>
);
}