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 JobRow({ job, onSave, saving, }: { job: SchedulerJob; onSave: (minutes: number) => void; saving: 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 ? ( {t("scheduler.active")} ) : next != null ? ( <>
{t("scheduler.nextRun")}
{fmtCountdown(next)}
) : ( "—" )}
); } 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") }), }); 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 })} /> ))}
{/* 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")}
); }