import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, AlertTriangle, Check, Clock, Database, Gauge, Pause, Pencil, Play, RadioTower, X, } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; const POLL_MS = 4000; // Seconds until an ISO instant (negative = past). function secsUntil(iso: string | null): number | null { if (!iso) return null; return Math.round((new Date(iso).getTime() - Date.now()) / 1000); } function fmtCountdown(s: number): string { if (s <= 0) return "now"; if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; const h = Math.floor(m / 60); return `${h}h ${m % 60}m`; } const DOT_CLASS: Record = { running: "bg-accent animate-pulse", ok: "bg-emerald-500", error: "bg-red-500", skipped: "bg-amber-500", idle: "bg-border", }; function statusKey(job: SchedulerJob): keyof typeof DOT_CLASS { if (job.running) return "running"; if (job.status === "ok") return "ok"; if (job.status === "error") return "error"; if (job.status === "skipped") return "skipped"; return "idle"; } function StatusDot({ k, withTooltip = true }: { k: keyof typeof DOT_CLASS; withTooltip?: boolean }) { const { t } = useTranslation(); const dot = ; return withTooltip ? {dot} : dot; } function StatusLegend() { const { t } = useTranslation(); return (
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => ( {t(`scheduler.dot.${k}`)} ))}
); } function JobProgress({ p }: { p: NonNullable }) { const { t } = useTranslation(); const phase = p.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : null; const pct = p.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null; return (
{phase} {p.total != null ? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}` : p.current.toLocaleString()}
{pct != null ? (
) : ( // Indeterminate: total unknown, so a slim moving sliver instead of a fill.
)}
); } function JobRow({ job, onSave, saving, onRun, runDisabled, }: { job: SchedulerJob; onSave: (minutes: number) => void; saving: boolean; onRun: () => void; runDisabled: boolean; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [val, setVal] = useState(String(job.interval_minutes)); const next = secsUntil(job.next_run); useEffect(() => { if (!editing) setVal(String(job.interval_minutes)); }, [job.interval_minutes, editing]); function save() { const m = parseInt(val, 10); if (Number.isFinite(m) && m >= 1 && m <= 1440 && m !== job.interval_minutes) onSave(m); setEditing(false); } return (
{t(`scheduler.jobs.${job.id}`, job.id)} {editing ? ( setVal(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") setEditing(false); }} className="w-16 bg-card border border-border rounded px-1.5 py-0.5 text-xs outline-none focus:border-accent" /> {t("scheduler.minutes")} ) : ( )}
{job.running ? t("scheduler.runningNow") : job.last_finished ? t("scheduler.lastRun", { time: relativeTime(job.last_finished) }) : t("scheduler.neverRun")} {job.last_error ? ( · {job.last_error} ) : job.last_result ? ( · {job.last_result} ) : null}
{job.running && job.progress && }
{job.running ? ( {t("scheduler.active")} ) : next != null ? ( <>
{t("scheduler.nextRun")}
{fmtCountdown(next)}
) : ( "—" )}
); } function MaintenanceSettings({ m, onSave, saving, }: { m: SchedulerStatus["maintenance"]; onSave: (batch: number) => void; saving: boolean; }) { const { t } = useTranslation(); const [val, setVal] = useState(String(m.revalidate_batch)); useEffect(() => setVal(String(m.revalidate_batch)), [m.revalidate_batch]); function save() { const n = parseInt(val, 10); if (Number.isFinite(n) && n >= m.min && n <= m.max && n !== m.revalidate_batch) onSave(n); } const dirty = String(m.revalidate_batch) !== val; return (
{t("scheduler.maintenance.batchLabel")} setVal(e.target.value)} onKeyDown={(e) => e.key === "Enter" && save()} className="w-28 bg-card border border-border rounded px-2 py-1 text-sm tabular-nums outline-none focus:border-accent" />
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
); } function Stat({ icon: Icon, label, value, hint, sub, }: { icon: typeof Database; label: string; value: string | number; hint?: string; sub?: string; }) { return (
{label}
{value}
{sub &&
{sub}
}
); } export default function Scheduler() { const { t } = useTranslation(); const qc = useQueryClient(); const q = useLiveQuery(["scheduler"], api.schedulerStatus, { intervalMs: POLL_MS, }); const data = q.data; // Tick once a second so the per-job countdowns move between polls. const [, setTick] = useState(0); useEffect(() => { const id = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(id); }, []); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), onError: () => notify({ level: "error", message: t("scheduler.toggleFailed") }), }); const intervalMut = useMutation({ mutationFn: (v: { jobId: string; minutes: number }) => api.updateSchedulerJob(v.jobId, v.minutes), onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), onError: () => notify({ level: "error", message: t("scheduler.intervalFailed") }), }); // Manual "run now" triggers. The wrapper runs in a background thread server-side; the // live poll surfaces the "running" state within a tick, so we just nudge a refetch. const runMut = useMutation({ mutationFn: (jobId: string) => api.runSchedulerJob(jobId), onSuccess: (_d, jobId) => { notify({ level: "success", message: t("scheduler.triggered", { job: t(`scheduler.jobs.${jobId}`, jobId) }) }); qc.invalidateQueries({ queryKey: ["scheduler"] }); }, }); const runAllMut = useMutation({ mutationFn: () => api.runAllSchedulerJobs(), onSuccess: (res) => { notify({ level: "success", message: t("scheduler.triggeredAll", { count: res.started.length }) }); qc.invalidateQueries({ queryKey: ["scheduler"] }); }, }); const batchMut = useMutation({ mutationFn: (v: number) => api.updateMaintenanceBatch(v), onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }), }); if (q.isLoading && !data) return
{t("scheduler.loading")}
; if (!data) return
{t("common.somethingWrong")}
; const quotaPct = data.quota.daily_budget ? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100)) : 0; return (
{/* Status strip */}
{t("scheduler.title")}
{data.paused ? t("scheduler.paused") : data.running_here ? t("scheduler.running") : t("scheduler.notHere")} {q.isFetching && · {t("scheduler.updating")}}
{!data.running_here && (
{t("scheduler.notHereNote")}
)} {/* Jobs */}
{t("scheduler.jobsTitle")}
{data.jobs.map((job) => ( intervalMut.mutate({ jobId: job.id, minutes })} onRun={() => runMut.mutate(job.id)} runDisabled={data.paused || runAllMut.isPending} /> ))}
{/* Queue */}
{t("scheduler.queueTitle")}
{/* Quota */}
{t("scheduler.quotaTitle")}
{t("scheduler.quotaUsed", { used: data.quota.used_today.toLocaleString(), budget: data.quota.daily_budget.toLocaleString(), })} {t("scheduler.quotaRemaining", { count: data.quota.remaining_today, formatted: data.quota.remaining_today.toLocaleString(), })}
90 ? "bg-red-500" : "bg-accent"}`} style={{ width: `${quotaPct}%` }} />
{t("scheduler.quotaNote")}
{/* Maintenance settings */}
{t("scheduler.maintenance.title")}
batchMut.mutate(batch)} saving={batchMut.isPending} />
); }