1"use client";
2
3
12import { useState } from "react";
13import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
14import { ListChecks, RefreshCw } from "lucide-react";
15import {
16 orgAdminApi,
17 type AdminQueueName,
18 type OrgFailedJob
19} from "@/lib/org-admin";
20import { isApiError } from "@/lib/api";
21import { toast } from "@/stores/ui-store";
22import { Section, inputCls } from "@/components/settings/settings-shell";
23
24const QUEUES: AdminQueueName[] = [
25 "files",
26 "search-index",
27 "webhooks",
28 "automations",
29 "email",
30 "ai",
31 "maintenance"
32];
33
34function fmtTime(iso: string | null): string {
35 if (!iso) return "—";
36 return new Date(iso).toLocaleString();
37}
38
39export function AdminJobsTab({ orgId }: { orgId: string }) {
40 const qc = useQueryClient();
41 const [queue, setQueue] = useState<AdminQueueName | "">("");
42
43 const overview = useQuery({
44 queryKey: ["orgs", orgId, "admin", "jobs"],
45 queryFn: () => orgAdminApi.jobsOverview(orgId),
46 retry: false,
47 });
48
49 const failed = useQuery({
50 queryKey: ["orgs", orgId, "admin", "jobs", "failed", queue],
51 queryFn: () => orgAdminApi.jobsFailed(orgId, { queue: queue || undefined, limit: 20 }),
52 retry: false,
53 });
54
55 const requeue = useMutation({
56 mutationFn: (v: { queue: AdminQueueName; jobId: string }) => orgAdminApi.jobsRequeue(orgId, v),
57 onSuccess: () => {
58 toast("success", "Job requeued");
59 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "jobs"] });
60 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "jobs", "failed"] });
61 },
62 onError: (e) =>
63 toast("error", "Could not requeue the job", isApiError(e) ? e.message : undefined)
64 });
65
66 if (overview.isLoading) {
67 return <p className="text-sm text-muted-foreground" role="status">Loading jobs…</p>;
68 }
69 if (overview.isError) {
70 return <p className="text-sm text-destructive" role="alert">Could not load the job state.</p>;
71 }
72 const counts = overview.data!.queues;
73 const jobs = failed.data?.jobs ?? [];
74
75 return (
76 <div className="space-y-6">
77 <Section
78 title="Queue state"
79 description="Counts are cluster-wide — every org shares the seven worker queues. The org-admin role is the access boundary."
80 >
81 <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-7">
82 {counts.map((c) => (
83 <div key={c.name} data-testid={`jobs-queue-${c.name}`} className="rounded-md border border-border p-2.5">
84 <div className="truncate text-xs font-medium" title={c.name}>
85 {c.name}
86 </div>
87 <div className="mt-1.5 flex flex-wrap gap-x-2.5 gap-y-0.5 text-[11px] text-muted-foreground">
88 <span title="waiting + delayed">
89 <span className="font-medium text-foreground">{c.waiting}</span> wait
90 </span>
91 <span title="active">
92 <span className="font-medium text-foreground">{c.active}</span> active
93 </span>
94 <span title="failed">
95 <span className={c.failed > 0 ? "font-medium text-destructive" : "font-medium text-foreground"}>
96 {c.failed}
97 </span>{" "}
98 fail
99 </span>
100 </div>
101 </div>
102 ))}
103 </div>
104 </Section>
105
106 <Section
107 title="Failed jobs"
108 description="Recent failed jobs (the DLQ view). Requeue resets the attempt counter and returns the job to waiting."
109 >
110 <div className="mb-3 flex items-center gap-2">
111 <ListChecks className="size-4 text-muted-foreground" aria-hidden />
112 <select
113 value={queue}
114 onChange={(e) => setQueue(e.target.value as AdminQueueName | "")}
115 className={inputCls}
116 aria-label="Filter failed jobs by queue"
117 >
118 <option value="">All queues</option>
119 {QUEUES.map((q) => (
120 <option key={q} value={q}>
121 {q}
122 </option>
123 ))}
124 </select>
125 </div>
126
127 {failed.isLoading ? (
128 <p className="text-sm text-muted-foreground" role="status">
129 Loading failed jobs…
130 </p>
131 ) : failed.isError ? (
132 <p className="text-sm text-destructive" role="alert">
133 Could not load failed jobs.
134 </p>
135 ) : jobs.length === 0 ? (
136 <p className="text-sm text-muted-foreground">No failed jobs{queue ? ` on ${queue}` : ""}.</p>
137 ) : (
138 <ul className="divide-y divide-border rounded-md border border-border">
139 {jobs.map((j) => (
140 <JobRow key={`${j.queue}-${j.jobId}`} job={j} onRequeue={() => requeue.mutate({ queue: j.queue, jobId: j.jobId! })} busy={requeue.isPending} />
141 ))}
142 </ul>
143 )}
144 </Section>
145 </div>
146 );
147}
148
149function JobRow({ job, onRequeue, busy }: { job: OrgFailedJob; onRequeue: () => void; busy: boolean }) {
150 return (
151 <li className="flex items-start gap-3 p-3">
152 <div className="min-w-0 flex-1">
153 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
154 <span className="text-sm font-medium">{job.name}</span>
155 <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{job.queue}</span>
156 <span className="text-[11px] text-muted-foreground">{job.attemptsMade} attempts</span>
157 <span className="text-[11px] text-muted-foreground">{fmtTime(job.failedAt)}</span>
158 </div>
159 {job.failedReason && (
160 <p className="mt-1 break-words rounded bg-destructive/5 px-2 py-1 text-xs text-destructive">{job.failedReason}</p>
161 )}
162 {job.payload && (
163 <pre className="mt-1 max-h-24 overflow-auto break-all rounded bg-muted/50 px-2 py-1 text-[11px] text-muted-foreground">
164 {job.payload}
165 </pre>
166 )}
167 </div>
168 <button
169 type="button"
170 onClick={onRequeue}
171 disabled={busy || !job.jobId}
172 data-testid={`jobs-requeue-${job.jobId}`}
173 className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-input px-2.5 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50"
174 >
175 <RefreshCw className="size-3.5" aria-hidden /> Requeue
176 </button>
177 </li>
178 );
179}
180