623 lines
26 KiB
TypeScript
623 lines
26 KiB
TypeScript
"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>
|
||
);
|
||
}
|