feat(scheduler): automatisations planifiées (cron) — tâche 5

- table schedules (migration 0007) + service scheduler (croner) : CRUD,
  runSchedule avec scope (all/liste), pool de concurrence et verrou par machine,
  mapping actions → refresh/metrics/docker_scan ; reloadSchedules au boot
- worker = reloadSchedules (remplace le refresh 30 min en dur)
- routes /api/schedules (CRUD + :id/run) ; cron invalide rejeté (validation croner)
- UI Paramètres : onglet « Automatisations » (liste, activer/lancer/supprimer, création)

tsc 0 · 113 tests · build OK · boot OK (migration 0007, CRUD vérifié).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 19:25:44 +02:00
parent bdbe7af55c
commit ff9cfaa9e1
11 changed files with 2796 additions and 18 deletions
+32
View File
@@ -55,6 +55,13 @@ export const api = {
req<DefaultAptProxy>("/settings/apt-proxy", { method: "PUT", body: JSON.stringify(body) }),
applyProxyToAll: () => req<{ ok: boolean; updated: number }>("/settings/apt-proxy/apply-all", { method: "POST" }),
// --- Automatisations planifiées ---
getSchedules: () => req<ScheduleView[]>("/schedules"),
createSchedule: (body: ScheduleInput) => req<ScheduleView>("/schedules", { method: "POST", body: JSON.stringify(body) }),
updateSchedule: (id: string, body: Partial<ScheduleInput>) => req<ScheduleView>(`/schedules/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteSchedule: (id: string) => req<{ ok: boolean }>(`/schedules/${id}`, { method: "DELETE" }),
runScheduleNow: (id: string) => req<{ ok: boolean }>(`/schedules/${id}/run`, { method: "POST" }),
// --- Profil machine (SJ-7) ---
updateMachine: (id: string, body: UpdateMachineBody) =>
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
@@ -219,6 +226,31 @@ export interface ProbeResultView {
changes: string[];
}
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
export interface ScheduleView {
id: string;
name: string;
enabled: boolean;
cron: string;
timezone: string | null;
scope: { machineIds: "all" | string[] };
actions: ScheduleAction[];
concurrency: number;
lastRunAt: string | null;
lastStatus: string | null;
}
export interface ScheduleInput {
name: string;
cron: string;
timezone?: string | null;
enabled?: boolean;
scope?: { machineIds: "all" | string[] };
actions: ScheduleAction[];
concurrency?: number;
}
export type StackStatus = "candidate" | "enabled" | "ignored" | "error";
export interface DockerSettingsView {
+109 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import type { AptProxyMode } from "@shared/types.js";
import { Icon, Popup, Button } from "../components/ui-kit.js";
import { api, type DbInfo } from "../lib/api.js";
import { api, type DbInfo, type ScheduleView, type ScheduleAction } from "../lib/api.js";
interface Props {
open: boolean;
@@ -14,6 +14,7 @@ type SettingsTab =
| "tiles"
| "layout"
| "proxy"
| "automation"
| "docker"
| "scripts"
| "hermes"
@@ -26,6 +27,7 @@ const TABS: Array<{ id: SettingsTab; label: string; icon: string }> = [
{ id: "tiles", label: "Tuiles", icon: "grid" },
{ id: "layout", label: "Volets", icon: "collapse" },
{ id: "proxy", label: "Proxy APT", icon: "network" },
{ id: "automation", label: "Automatisations", icon: "clock" },
{ id: "docker", label: "Docker", icon: "docker" },
{ id: "scripts", label: "Scripts", icon: "script" },
{ id: "hermes", label: "Hermes", icon: "node" },
@@ -71,6 +73,7 @@ export function SettingsModal({ open, onClose }: Props) {
{active === "tiles" && <TileSettings />}
{active === "layout" && <LayoutSettings />}
{active === "proxy" && <ProxyDefaultSettings />}
{active === "automation" && <AutomationSettings />}
{active === "docker" && <DockerSettings />}
{active === "scripts" && <ScriptsSettings />}
{active === "hermes" && <HermesSettings />}
@@ -239,6 +242,111 @@ function RetentionSettings() {
);
}
const SCHEDULE_ACTIONS: { id: ScheduleAction; label: string }[] = [
{ id: "apt_update_analyze", label: "Analyse APT" },
{ id: "machine_metrics_simple", label: "Métriques" },
{ id: "docker_scan", label: "Scan Docker" },
];
function AutomationSettings() {
const [schedules, setSchedules] = useState<ScheduleView[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "error"; text: string } | null>(null);
const [name, setName] = useState("Analyse quotidienne");
const [cron, setCron] = useState("0 6 * * *");
const [actions, setActions] = useState<ScheduleAction[]>(["apt_update_analyze", "machine_metrics_simple"]);
async function load() {
try {
setSchedules(await api.getSchedules());
} catch (e) {
setMsg({ kind: "error", text: (e as Error).message });
}
}
useEffect(() => {
void load();
}, []);
async function withBusy(key: string, fn: () => Promise<void>) {
setBusy(key);
setMsg(null);
try {
await fn();
} catch (e) {
setMsg({ kind: "error", text: (e as Error).message });
} finally {
setBusy(null);
}
}
const toggleAction = (a: ScheduleAction) =>
setActions((prev) => (prev.includes(a) ? prev.filter((x) => x !== a) : [...prev, a]));
const create = () =>
withBusy("create", async () => {
if (!actions.length) throw new Error("Sélectionne au moins une action.");
await api.createSchedule({ name, cron, actions, scope: { machineIds: "all" } });
await load();
setMsg({ kind: "ok", text: "Automatisation créée." });
});
return (
<SettingsSection title="Automatisations planifiées">
<div className="machine-list" style={{ gap: 8 }}>
{schedules.length === 0 && <span className="machine-placeholder">Aucune automatisation. Crée-en une ci-dessous.</span>}
{schedules.map((s) => (
<div key={s.id} className="docker-stack">
<div className="docker-stack-head">
<span className="docker-stack-name">{s.name}</span>
<span className={`docker-badge docker-badge-${s.enabled ? "ok" : "off"}`}>{s.enabled ? "actif" : "off"}</span>
<span className="docker-stack-by mono">{s.cron}</span>
</div>
<div className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>
{s.actions.join(" · ")} · toutes machines{s.lastRunAt ? ` · dernier ${new Date(s.lastRunAt).toLocaleString("fr-FR")} (${s.lastStatus})` : ""}
</div>
<div className="docker-stack-actions">
<Button icon="play" size="sm" onClick={busy ? undefined : () => withBusy(`run:${s.id}`, async () => { await api.runScheduleNow(s.id); setMsg({ kind: "ok", text: `${s.name} lancé.` }); })}>
{busy === `run:${s.id}` ? "…" : "Lancer"}
</Button>
<Button icon="check" size="sm" variant="ghost" onClick={busy ? undefined : () => withBusy(`tog:${s.id}`, async () => { await api.updateSchedule(s.id, { enabled: !s.enabled }); await load(); })}>
{s.enabled ? "Désactiver" : "Activer"}
</Button>
<Button icon="trash" size="sm" variant="danger" onClick={busy ? undefined : () => withBusy(`del:${s.id}`, async () => { await api.deleteSchedule(s.id); await load(); })}>
Supprimer
</Button>
</div>
</div>
))}
</div>
<div className="cfg-block" style={{ marginTop: 14 }}>
<span className="label">Nouvelle automatisation</span>
<div className="settings-fields">
<Field label="Nom"><input className="su-field" value={name} onChange={(e) => setName(e.target.value)} /></Field>
<Field label="Cron (min h j m jsem)"><input className="su-field" value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 6 * * *" /></Field>
</div>
<Field label="Actions">
<div className="settings-checks">
{SCHEDULE_ACTIONS.map((a) => (
<label key={a.id} className="settings-check">
<input type="checkbox" checked={actions.includes(a.id)} onChange={() => toggleAction(a.id)} />
<span>{a.label}</span>
</label>
))}
</div>
</Field>
<div className="settings-actions">
<Button icon="check" variant="primary" onClick={busy ? undefined : create}>
{busy === "create" ? "Création…" : "Créer (toutes machines)"}
</Button>
</div>
</div>
{msg && <p className={`settings-note ${msg.kind === "error" ? "settings-note-err" : "settings-note-ok"}`}>{msg.text}</p>}
</SettingsSection>
);
}
function ProxyDefaultSettings() {
const [mode, setMode] = useState<AptProxyMode>("direct");
const [url, setUrl] = useState("");