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")}
);
}