2026-06-16 14:38:51 +02:00
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import {
|
|
|
|
|
Activity,
|
|
|
|
|
AlertTriangle,
|
2026-06-16 15:58:23 +02:00
|
|
|
Check,
|
2026-06-16 14:38:51 +02:00
|
|
|
Clock,
|
|
|
|
|
Database,
|
|
|
|
|
Gauge,
|
|
|
|
|
Pause,
|
2026-06-16 15:58:23 +02:00
|
|
|
Pencil,
|
2026-06-16 14:38:51 +02:00
|
|
|
Play,
|
|
|
|
|
RadioTower,
|
2026-06-16 15:58:23 +02:00
|
|
|
X,
|
2026-06-16 14:38:51 +02:00
|
|
|
} 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;
|
2026-06-19 02:43:46 +02:00
|
|
|
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
|
|
|
|
|
const FAST_POLL_MS = 1500;
|
2026-06-16 14:38:51 +02:00
|
|
|
|
|
|
|
|
// 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`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 15:58:23 +02:00
|
|
|
const DOT_CLASS: Record<string, string> = {
|
|
|
|
|
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 = <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${DOT_CLASS[k]}`} />;
|
|
|
|
|
return withTooltip ? <Tooltip hint={t(`scheduler.dot.${k}`)}>{dot}</Tooltip> : dot;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusLegend() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap text-[11px] text-muted mb-2">
|
|
|
|
|
{(["running", "ok", "idle", "error", "skipped"] as const).map((k) => (
|
|
|
|
|
<span key={k} className="inline-flex items-center gap-1.5">
|
|
|
|
|
<StatusDot k={k} withTooltip={false} />
|
|
|
|
|
{t(`scheduler.dot.${k}`)}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-06-16 14:38:51 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-19 02:43:46 +02:00
|
|
|
// 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"] }) {
|
2026-06-18 04:01:10 +02:00
|
|
|
const { t } = useTranslation();
|
2026-06-19 02:43:46 +02:00
|
|
|
const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
|
2026-06-18 04:01:10 +02:00
|
|
|
const pct =
|
2026-06-19 02:43:46 +02:00
|
|
|
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
|
2026-06-18 04:01:10 +02:00
|
|
|
return (
|
|
|
|
|
<div className="mt-1.5">
|
|
|
|
|
<div className="flex items-center justify-between text-[11px] text-muted mb-0.5">
|
|
|
|
|
<span className="truncate">{phase}</span>
|
|
|
|
|
<span className="tabular-nums shrink-0">
|
2026-06-19 02:43:46 +02:00
|
|
|
{p?.total != null
|
2026-06-18 04:01:10 +02:00
|
|
|
? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
|
2026-06-19 02:43:46 +02:00
|
|
|
: p
|
|
|
|
|
? p.current.toLocaleString()
|
|
|
|
|
: ""}
|
2026-06-18 04:01:10 +02:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-1.5 rounded-full bg-border overflow-hidden">
|
|
|
|
|
{pct != null ? (
|
|
|
|
|
<div className="h-full rounded-full bg-accent transition-[width]" style={{ width: `${pct}%` }} />
|
|
|
|
|
) : (
|
|
|
|
|
// Indeterminate: total unknown, so a slim moving sliver instead of a fill.
|
|
|
|
|
<div className="h-full w-1/3 rounded-full bg-accent animate-pulse" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 15:58:23 +02:00
|
|
|
function JobRow({
|
|
|
|
|
job,
|
|
|
|
|
onSave,
|
|
|
|
|
saving,
|
2026-06-18 04:01:10 +02:00
|
|
|
onRun,
|
|
|
|
|
runDisabled,
|
2026-06-16 15:58:23 +02:00
|
|
|
}: {
|
|
|
|
|
job: SchedulerJob;
|
|
|
|
|
onSave: (minutes: number) => void;
|
|
|
|
|
saving: boolean;
|
2026-06-18 04:01:10 +02:00
|
|
|
onRun: () => void;
|
|
|
|
|
runDisabled: boolean;
|
2026-06-16 15:58:23 +02:00
|
|
|
}) {
|
2026-06-16 14:38:51 +02:00
|
|
|
const { t } = useTranslation();
|
2026-06-16 15:58:23 +02:00
|
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
|
const [val, setVal] = useState(String(job.interval_minutes));
|
2026-06-16 14:38:51 +02:00
|
|
|
const next = secsUntil(job.next_run);
|
2026-06-16 15:58:23 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 14:38:51 +02:00
|
|
|
return (
|
|
|
|
|
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
2026-06-16 15:58:23 +02:00
|
|
|
<StatusDot k={statusKey(job)} />
|
2026-06-16 14:38:51 +02:00
|
|
|
<div className="min-w-0 flex-1">
|
2026-06-16 15:58:23 +02:00
|
|
|
<div className="text-sm font-medium flex items-center gap-1.5 flex-wrap">
|
|
|
|
|
<Tooltip hint={t(`scheduler.jobDesc.${job.id}`)}>
|
|
|
|
|
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
|
|
|
|
|
{t(`scheduler.jobs.${job.id}`, job.id)}
|
|
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
{editing ? (
|
|
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
max={1440}
|
|
|
|
|
value={val}
|
|
|
|
|
autoFocus
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
|
|
|
|
|
<button onClick={save} disabled={saving} className="text-emerald-400 hover:opacity-80 disabled:opacity-50" aria-label={t("common.save")}>
|
|
|
|
|
<Check className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={() => setEditing(false)} className="text-muted hover:text-fg" aria-label={t("common.cancel")}>
|
|
|
|
|
<X className="w-3.5 h-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<Tooltip hint={t("scheduler.editInterval")}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setEditing(true)}
|
|
|
|
|
className="group text-muted font-normal hover:text-accent inline-flex items-center gap-1 transition"
|
|
|
|
|
>
|
|
|
|
|
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
|
|
|
|
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 transition" />
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
)}
|
2026-06-16 14:38:51 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="text-[11px] text-muted truncate">
|
|
|
|
|
{job.running
|
|
|
|
|
? t("scheduler.runningNow")
|
|
|
|
|
: job.last_finished
|
|
|
|
|
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
|
|
|
|
|
: t("scheduler.neverRun")}
|
|
|
|
|
{job.last_error ? (
|
|
|
|
|
<span className="text-red-400"> · {job.last_error}</span>
|
|
|
|
|
) : job.last_result ? (
|
|
|
|
|
<span> · {job.last_result}</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-06-19 02:43:46 +02:00
|
|
|
{job.running && <JobProgress p={job.progress} />}
|
2026-06-16 14:38:51 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="shrink-0 text-right text-[11px] text-muted tabular-nums">
|
|
|
|
|
{job.running ? (
|
|
|
|
|
<span className="text-accent">{t("scheduler.active")}</span>
|
|
|
|
|
) : next != null ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="uppercase tracking-wide text-[10px]">{t("scheduler.nextRun")}</div>
|
|
|
|
|
<div>{fmtCountdown(next)}</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
"—"
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-18 04:01:10 +02:00
|
|
|
<Tooltip hint={t("scheduler.runNow")}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onRun}
|
|
|
|
|
disabled={job.running || runDisabled}
|
|
|
|
|
aria-label={t("scheduler.runNow")}
|
|
|
|
|
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
|
|
|
|
|
>
|
|
|
|
|
<Play className="w-4 h-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
2026-06-16 14:38:51 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 04:37:08 +02:00
|
|
|
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 (
|
|
|
|
|
<div className="glass-card rounded-xl p-4">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
|
|
|
|
<Database className="w-4 h-4 text-muted shrink-0" />
|
|
|
|
|
<Tooltip hint={t("scheduler.maintenance.batchHint")}>
|
|
|
|
|
<span className="underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help">
|
|
|
|
|
{t("scheduler.maintenance.batchLabel")}
|
|
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
<span className="flex-1" />
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min={m.min}
|
|
|
|
|
max={m.max}
|
|
|
|
|
step={1000}
|
|
|
|
|
value={val}
|
|
|
|
|
onChange={(e) => 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"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
onClick={save}
|
|
|
|
|
disabled={!dirty || saving}
|
|
|
|
|
className="glass-card glass-hover px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
|
|
|
|
>
|
|
|
|
|
{t("common.save")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[11px] text-muted mt-1.5">
|
|
|
|
|
{t("scheduler.maintenance.batchNote", { default: m.revalidate_batch_default.toLocaleString() })}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 14:38:51 +02:00
|
|
|
function Stat({
|
|
|
|
|
icon: Icon,
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
hint,
|
|
|
|
|
sub,
|
|
|
|
|
}: {
|
|
|
|
|
icon: typeof Database;
|
|
|
|
|
label: string;
|
|
|
|
|
value: string | number;
|
|
|
|
|
hint?: string;
|
|
|
|
|
sub?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="glass-card rounded-xl p-3">
|
|
|
|
|
<div className="flex items-center gap-2 text-muted text-xs">
|
|
|
|
|
<Icon className="w-4 h-4 shrink-0" />
|
|
|
|
|
<Tooltip hint={hint ?? ""}>
|
|
|
|
|
<span className={hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""}>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xl font-semibold mt-1 tabular-nums">{value}</div>
|
|
|
|
|
{sub && <div className="text-[11px] text-muted">{sub}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Scheduler() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const qc = useQueryClient();
|
2026-06-19 02:43:46 +02:00
|
|
|
// 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).
|
2026-06-16 14:38:51 +02:00
|
|
|
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
|
2026-06-19 02:43:46 +02:00
|
|
|
intervalMs: (d) => (d?.jobs?.some((j) => j.running) ? FAST_POLL_MS : POLL_MS),
|
2026-06-16 14:38:51 +02:00
|
|
|
});
|
|
|
|
|
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") }),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 15:58:23 +02:00
|
|
|
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") }),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-18 04:01:10 +02:00
|
|
|
// 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"] });
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-06-18 04:37:08 +02:00
|
|
|
const batchMut = useMutation({
|
|
|
|
|
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
|
|
|
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["scheduler"] }),
|
|
|
|
|
});
|
2026-06-18 04:01:10 +02:00
|
|
|
|
2026-06-16 14:38:51 +02:00
|
|
|
if (q.isLoading && !data)
|
|
|
|
|
return <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
|
|
|
|
if (!data)
|
|
|
|
|
return <div className="p-8 text-muted">{t("common.somethingWrong")}</div>;
|
|
|
|
|
|
|
|
|
|
const quotaPct = data.quota.daily_budget
|
|
|
|
|
? Math.min(100, Math.round((data.quota.used_today / data.quota.daily_budget) * 100))
|
|
|
|
|
: 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-4 max-w-4xl w-full mx-auto space-y-4">
|
|
|
|
|
{/* Status strip */}
|
|
|
|
|
<div className="glass rounded-2xl p-4 flex flex-wrap items-center gap-3">
|
|
|
|
|
<Activity className="w-5 h-5 text-accent shrink-0" />
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="font-semibold">{t("scheduler.title")}</div>
|
|
|
|
|
<div className="text-xs text-muted flex items-center gap-2 flex-wrap">
|
|
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
|
|
<span
|
|
|
|
|
className={`w-2 h-2 rounded-full ${
|
|
|
|
|
data.paused ? "bg-amber-500" : data.running_here ? "bg-emerald-500" : "bg-border"
|
|
|
|
|
}`}
|
|
|
|
|
/>
|
|
|
|
|
{data.paused
|
|
|
|
|
? t("scheduler.paused")
|
|
|
|
|
: data.running_here
|
|
|
|
|
? t("scheduler.running")
|
|
|
|
|
: t("scheduler.notHere")}
|
|
|
|
|
</span>
|
|
|
|
|
{q.isFetching && <span className="text-accent">· {t("scheduler.updating")}</span>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1" />
|
2026-06-18 04:01:10 +02:00
|
|
|
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => runAllMut.mutate()}
|
|
|
|
|
disabled={runAllMut.isPending || data.paused}
|
|
|
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
|
|
|
>
|
|
|
|
|
<Play className="w-4 h-4" />
|
|
|
|
|
{t("scheduler.runAll")}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
2026-06-16 14:38:51 +02:00
|
|
|
<Tooltip hint={t("scheduler.pauseHint")}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => pauseResume.mutate(data.paused)}
|
|
|
|
|
disabled={pauseResume.isPending}
|
|
|
|
|
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
|
|
|
|
>
|
|
|
|
|
{data.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
|
|
|
|
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
|
|
|
|
|
</button>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!data.running_here && (
|
|
|
|
|
<div className="flex items-center gap-2 text-sm bg-amber-500/15 border border-amber-500/30 rounded-xl px-3 py-2">
|
|
|
|
|
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0" />
|
|
|
|
|
<span className="text-muted">{t("scheduler.notHereNote")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Jobs */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
|
2026-06-16 15:58:23 +02:00
|
|
|
<StatusLegend />
|
2026-06-16 14:38:51 +02:00
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{data.jobs.map((job) => (
|
2026-06-16 15:58:23 +02:00
|
|
|
<JobRow
|
|
|
|
|
key={job.id}
|
|
|
|
|
job={job}
|
|
|
|
|
saving={intervalMut.isPending}
|
|
|
|
|
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
|
2026-06-18 04:01:10 +02:00
|
|
|
onRun={() => runMut.mutate(job.id)}
|
|
|
|
|
runDisabled={data.paused || runAllMut.isPending}
|
2026-06-16 15:58:23 +02:00
|
|
|
/>
|
2026-06-16 14:38:51 +02:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Queue */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.queueTitle")}</div>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
|
|
|
<Stat
|
|
|
|
|
icon={Database}
|
|
|
|
|
label={t("scheduler.queue.recentPending")}
|
|
|
|
|
hint={t("scheduler.queue.recentPendingHint")}
|
|
|
|
|
value={data.queue.channels_recent_pending.toLocaleString()}
|
|
|
|
|
/>
|
|
|
|
|
<Stat
|
|
|
|
|
icon={Database}
|
|
|
|
|
label={t("scheduler.queue.deepPending")}
|
|
|
|
|
hint={t("scheduler.queue.deepPendingHint")}
|
|
|
|
|
value={data.queue.channels_deep_pending.toLocaleString()}
|
|
|
|
|
/>
|
|
|
|
|
<Stat
|
|
|
|
|
icon={Clock}
|
|
|
|
|
label={t("scheduler.queue.enrichPending")}
|
|
|
|
|
hint={t("scheduler.queue.enrichPendingHint")}
|
|
|
|
|
value={data.queue.videos_pending_enrich.toLocaleString()}
|
|
|
|
|
/>
|
|
|
|
|
<Stat
|
|
|
|
|
icon={Clock}
|
|
|
|
|
label={t("scheduler.queue.shortsPending")}
|
|
|
|
|
hint={t("scheduler.queue.shortsPendingHint")}
|
|
|
|
|
value={data.queue.videos_pending_shorts.toLocaleString()}
|
|
|
|
|
/>
|
|
|
|
|
<Stat
|
|
|
|
|
icon={RadioTower}
|
|
|
|
|
label={t("scheduler.queue.liveRefresh")}
|
|
|
|
|
hint={t("scheduler.queue.liveRefreshHint")}
|
|
|
|
|
value={data.queue.videos_live_refresh.toLocaleString()}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Quota */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.quotaTitle")}</div>
|
|
|
|
|
<div className="glass-card rounded-xl p-4">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm">
|
|
|
|
|
<Gauge className="w-4 h-4 text-muted shrink-0" />
|
|
|
|
|
<span>
|
|
|
|
|
{t("scheduler.quotaUsed", {
|
|
|
|
|
used: data.quota.used_today.toLocaleString(),
|
|
|
|
|
budget: data.quota.daily_budget.toLocaleString(),
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="flex-1" />
|
|
|
|
|
<span className="text-muted text-xs">
|
|
|
|
|
{t("scheduler.quotaRemaining", {
|
|
|
|
|
count: data.quota.remaining_today,
|
|
|
|
|
formatted: data.quota.remaining_today.toLocaleString(),
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 h-2 rounded-full bg-border overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className={`h-full rounded-full ${quotaPct > 90 ? "bg-red-500" : "bg-accent"}`}
|
|
|
|
|
style={{ width: `${quotaPct}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-[11px] text-muted mt-1.5">{t("scheduler.quotaNote")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-18 04:37:08 +02:00
|
|
|
|
|
|
|
|
{/* Maintenance settings */}
|
|
|
|
|
<div>
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.maintenance.title")}</div>
|
|
|
|
|
<MaintenanceSettings
|
|
|
|
|
m={data.maintenance}
|
|
|
|
|
onSave={(batch) => batchMut.mutate(batch)}
|
|
|
|
|
saving={batchMut.isPending}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-06-16 14:38:51 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|