Files
hope-events/frontend/src/components/admin/JobsTab.tsx
T
joshuaandClaude Opus 5.5 48660732fe Run background jobs on a pg-boss queue; add e2e tests, CI and deploy script
Timed jobs move from in-process timers to a job registry run by pg-boss:
once per schedule across processes, retries, run history, cron schedules
in the church timezone, and an admin Background jobs page with Run now.
Playwright end-to-end tests run against their own seeded database. A
Gitea Actions workflow runs backend tests, frontend checks and the e2e
tests, and deploys v* tags with scripts/deploy.sh once secrets are set.
Also fixes the at-the-door check-in count for multi-day tickets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:49:46 +02:00

144 lines
7.6 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Timer, Play, RefreshCw } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { errorMessage } from "@/lib/utils";
type JobRun = { state: string; createdOn?: string; startedOn: string | null; completedOn: string | null; output: unknown };
type JobInfo = { name: string; label: string; cron: string; enabled: boolean; envFlag: string | null; nextRun: string | null; recent: JobRun[] };
type JobsResponse = { mode: "queue" | "timers" | "off"; timezone: string; jobs: JobInfo[] };
const STATE: Record<string, string> = {
completed: "bg-green-50 text-green-800",
failed: "bg-red-50 text-red-700",
active: "bg-blue-50 text-blue-700",
created: "bg-gray-100 text-gray-600",
retry: "bg-amber-50 text-amber-800",
cancelled: "bg-gray-100 text-gray-500",
};
const when = (d: string | null) => (d ? new Date(d).toLocaleString("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }) : "—");
function describeCron(cron: string): string {
const m = /^\*\/(\d+) \* \* \* \*$/.exec(cron);
if (m) return `Every ${m[1]} minutes`;
const d = /^(\d+) (\d+) \* \* \*$/.exec(cron);
if (d) return `Daily at ${d[2].padStart(2, "0")}:${d[1].padStart(2, "0")}`;
return cron;
}
function outputText(run: JobRun): string {
if (run.output === null || run.output === undefined) return "";
if (typeof run.output === "object" && run.output && "message" in run.output && run.state === "failed") return String((run.output as { message: unknown }).message);
try { return JSON.stringify(run.output); } catch { return ""; }
}
// Settings → Background jobs: what runs when, the last runs of each job, and "Run now".
export function JobsTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [data, setData] = useState<JobsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
setData(await apiFetch<JobsResponse>("/api/admin/jobs", { authToken: token }));
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Failed to load background jobs" });
} finally {
setLoading(false);
}
}, [token, setMessage]);
useEffect(() => { if (active) load(); }, [active, load]);
const runNow = async (job: JobInfo) => {
if (!token) return;
setRunning(job.name);
try {
const r = await apiFetch<{ queued: boolean }>(`/api/admin/jobs/${job.name}/run`, { method: "POST", authToken: token });
setMessage({ type: "ok", text: r.queued ? `"${job.label}" queued — it starts within a few seconds.` : `"${job.label}" ran.` });
setTimeout(load, r.queued ? 4000 : 0);
} catch (e) {
setMessage({ type: "err", text: errorMessage(e) || "Couldn't run the job" });
} finally {
setRunning(null);
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2"><Timer className="w-5 h-5 text-brand-600" /> Background jobs</h2>
<p className="text-sm text-gray-500 mt-0.5">
Tasks the server runs on a schedule ({data?.timezone || "local"} time). Each runs once per schedule, even with more than one server process, and failed runs are retried.
</p>
</div>
<button type="button" onClick={load} disabled={loading} className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50 disabled:opacity-50 shrink-0">
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} /> Refresh
</button>
</div>
{message && <div className={`p-3 border rounded text-sm ${message.type === "ok" ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
{data?.mode === "timers" && (
<div className="p-3 border rounded bg-amber-50 text-amber-900 text-sm">The job queue couldn&apos;t start, so jobs are running on simple timers in this server process (no retries, history only since the last restart). Check the server log for &quot;[jobs]&quot;.</div>
)}
{data?.mode === "off" && (
<div className="p-3 border rounded bg-gray-50 text-gray-700 text-sm">All background jobs are switched off (JOBS_ENABLED=false).</div>
)}
<ul className="divide-y border rounded-lg">
{(data?.jobs || []).map(job => {
const last = job.recent[0];
return (
<li key={job.name} className="p-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 mr-auto">
<div className="font-medium text-sm text-gray-900">{job.label}</div>
<div className="text-xs text-gray-500">
{job.enabled ? `${describeCron(job.cron)} · next ${when(job.nextRun)}` : `Off${job.envFlag ? ` (${job.envFlag}=false)` : ""}`}
</div>
</div>
{last && (
<button type="button" onClick={() => setOpen(open === job.name ? null : job.name)} className={`text-xs px-2 py-0.5 rounded ${STATE[last.state] || "bg-gray-100 text-gray-600"}`} title="Show recent runs">
{last.state} · {when(last.completedOn || last.startedOn || last.createdOn || null)}
</button>
)}
<button type="button" disabled={running !== null || data?.mode === "off"} onClick={() => runNow(job)}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
<Play className="w-3.5 h-3.5" /> {running === job.name ? "Starting…" : "Run now"}
</button>
</div>
{open === job.name && job.recent.length > 0 && (
<table className="mt-2 w-full text-xs">
<thead className="text-gray-500"><tr><th className="text-left font-medium py-1">Started</th><th className="text-left font-medium">Finished</th><th className="text-left font-medium">State</th><th className="text-left font-medium">Result</th></tr></thead>
<tbody>
{job.recent.map((r, i) => (
<tr key={i} className="border-t">
<td className="py-1 pr-2 whitespace-nowrap">{when(r.startedOn)}</td>
<td className="pr-2 whitespace-nowrap">{when(r.completedOn)}</td>
<td className="pr-2"><span className={`px-1.5 py-0.5 rounded ${STATE[r.state] || ""}`}>{r.state}</span></td>
<td className="font-mono text-[11px] text-gray-600 break-all">{outputText(r)}</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
})}
{!loading && data && data.jobs.length === 0 && <li className="p-3 text-sm text-gray-500">No jobs.</li>}
</ul>
<p className="text-xs text-gray-400">Change a schedule with the env var JOB_SCHEDULE_&lt;NAME&gt; (cron, e.g. JOB_SCHEDULE_WAITLIST=&quot;*/5 * * * *&quot;), or switch one off with its own flag.</p>
</div>
);
}