import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, AlertTriangle, Clock, Database, Gauge, Pause, Play, RadioTower, } 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`; } function StatusDot({ job }: { job: SchedulerJob }) { const color = job.running ? "bg-accent animate-pulse" : job.status === "ok" ? "bg-emerald-500" : job.status === "error" ? "bg-red-500" : job.status === "skipped" ? "bg-amber-500" : "bg-border"; return ; } function JobRow({ job }: { job: SchedulerJob }) { const { t } = useTranslation(); const next = secsUntil(job.next_run); return (
{t(`scheduler.jobs.${job.id}`, job.id)} {" "} · {t("scheduler.everyMin", { count: job.interval_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") }), }); 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) => ( ))}
{/* 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")}
); }