import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import i18n from "../i18n"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, AlertTriangle, Check, Clock, Database, Gauge, Pause, Pencil, Play, RadioTower, Trash2, X, } from "lucide-react"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { useConfirm } from "./ConfirmProvider"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; const POLL_MS = 4000; // While any job is running, poll faster so short scheduled runs aren't missed between ticks. const FAST_POLL_MS = 1500; // 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 i18n.t("time.eta.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}`)} ))}
); } // Shown for ANY running job. With reported counts it's a determinate bar; for a job that // runs but doesn't report progress (or hasn't yet) it falls back to a generic "working" // label and an indeterminate sliver — so every active run is visible, not just the ones // that report numbers. function JobProgress({ p }: { p: SchedulerJob["progress"] }) { const { t } = useTranslation(); const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working"); 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 ? 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.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()} aria-label={t("scheduler.maintenance.batchLabel")} 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 confirm = useConfirm(); // Poll faster while any job is running so live progress is smooth, then ease back when // idle. Derived from the freshest data by react-query itself (see useLiveQuery). const q = useLiveQuery(["scheduler"], api.schedulerStatus, { intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS), }); const data = q.data; // Tick once a second so the per-job countdowns move between polls — but only while the tab is // visible; a hidden tab doesn't need to re-render the whole page every second (SB3). const [, setTick] = useState(0); useEffect(() => { let id: ReturnType | undefined; const start = () => { if (id == null) id = setInterval(() => setTick((n) => n + 1), 1000); }; const stop = () => { if (id != null) { clearInterval(id); id = undefined; } }; const onVisibility = () => { if (document.hidden) stop(); else { setTick((n) => n + 1); // snap the countdown to the correct value on return start(); } }; onVisibility(); // honor the current visibility on mount document.addEventListener("visibilitychange", onVisibility); return () => { stop(); document.removeEventListener("visibilitychange", onVisibility); }; }, []); 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"] }), }); const purgeMut = useMutation({ mutationFn: () => api.purgeDiscovery(), onSuccess: (res) => { const removed = (res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0); notify({ level: "success", message: t("scheduler.purgedDiscovery", { count: removed }) }); 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} />
); }