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>
144 lines
7.6 KiB
TypeScript
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'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 "[jobs]".</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_<NAME> (cron, e.g. JOB_SCHEDULE_WAITLIST="*/5 * * * *"), or switch one off with its own flag.</p>
|
|
</div>
|
|
);
|
|
}
|