feat(scheduler): per-job help tooltips, status legend, inline interval edit
Each job shows a tooltip (what it does + what happens if it stops), a status dot legend + per-dot tooltips clarify the colours, and the interval is inline- editable (pencil -> number -> save) wired to the new PATCH endpoint. HU/EN/DE.
This commit is contained in:
parent
84aebe16c7
commit
2a02fb353e
5 changed files with 175 additions and 20 deletions
|
|
@ -4,12 +4,15 @@ 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";
|
||||
|
|
@ -34,32 +37,109 @@ function fmtCountdown(s: number): string {
|
|||
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 <span className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${color}`} />;
|
||||
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 JobRow({ job }: { job: SchedulerJob }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="glass-card rounded-xl p-3 flex items-center gap-3">
|
||||
<StatusDot job={job} />
|
||||
<StatusDot k={statusKey(job)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{t(`scheduler.jobs.${job.id}`, job.id)}
|
||||
<span className="text-muted font-normal">
|
||||
{" "}
|
||||
· {t("scheduler.everyMin", { count: job.interval_minutes })}
|
||||
</span>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{job.running
|
||||
|
|
@ -140,6 +220,13 @@ export default function 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 <div className="p-8 text-muted">{t("scheduler.loading")}</div>;
|
||||
if (!data)
|
||||
|
|
@ -195,9 +282,15 @@ export default function Scheduler() {
|
|||
{/* Jobs */}
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">{t("scheduler.jobsTitle")}</div>
|
||||
<StatusLegend />
|
||||
<div className="space-y-1.5">
|
||||
{data.jobs.map((job) => (
|
||||
<JobRow key={job.id} job={job} />
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
saving={intervalMut.isPending}
|
||||
onSave={(minutes) => intervalMut.mutate({ jobId: job.id, minutes })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue