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:
@@ -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 {
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Tâche 5 — Automatisations planifiées (scheduler croner)
|
||||
|
||||
> Statut : **implémenté** (2026-06-06). tsc 0 · 113 tests · build OK · boot OK.
|
||||
> Réf. : `tache5.md §4` (automatisations backend), `validation_tache5.md`.
|
||||
|
||||
## Périmètre livré (1re tranche tâche 5)
|
||||
|
||||
Planificateur piloté par la BDD : exécuter `apt_update_analyze` / `machine_metrics_simple` /
|
||||
`docker_scan` sur un périmètre de machines à heure fixe (cron), avec concurrence + verrou.
|
||||
|
||||
- **Table `schedules`** (migration 0007) : name, enabled, cron, timezone, scope_json,
|
||||
actions_json, concurrency, notify_on_json, last_run_at, last_status.
|
||||
- **`server/services/scheduler.ts`** :
|
||||
- CRUD (validation cron via `new Cron()` à la création/maj).
|
||||
- `runSchedule` : résout le scope (`all` ou liste), exécute les actions par machine avec
|
||||
**pool de concurrence** + **verrou par machine** (in-process Set, évite 2 actions
|
||||
simultanées), met à jour last_run_at/last_status, `recordEvent` sur échec.
|
||||
- mapping actions → `refreshMachine` / `collectMetrics` / `scanDockerStacks`.
|
||||
- `reloadSchedules` : (ré)enregistre les crons actifs via croner (timezone par schedule).
|
||||
- **`worker.ts`** : `startWorker` = `reloadSchedules()` (remplace le refresh 30 min en dur).
|
||||
- **Routes** `/api/schedules` : list / create / get / patch / delete / `:id/run` (lancement immédiat).
|
||||
- **UI** : onglet Paramètres « Automatisations » — liste (cron, actions, actif, dernier run),
|
||||
activer/désactiver, lancer maintenant, supprimer, et formulaire de création (nom, cron,
|
||||
cases d'actions, toutes machines).
|
||||
|
||||
## Vérifié
|
||||
|
||||
CRUD via API, cron invalide rejeté proprement (message croner), init scheduler sans erreur,
|
||||
migration 0007 appliquée. Verrou empêche les exécutions concurrentes sur une même machine.
|
||||
|
||||
## Reste tâche 5 (backlog)
|
||||
|
||||
Notifications (`notifyOn`), tags de scope, retries persistants, extraction structurée des
|
||||
**messages importants** (E:/W:/dépréciations → `important_messages` + tuile), timeline
|
||||
d'événements machine, politiques de rétention. (pg-boss = piste future si jobs distribués.)
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `schedules` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`enabled` integer DEFAULT 1 NOT NULL,
|
||||
`cron` text NOT NULL,
|
||||
`timezone` text,
|
||||
`scope_json` text NOT NULL,
|
||||
`actions_json` text NOT NULL,
|
||||
`concurrency` integer DEFAULT 2 NOT NULL,
|
||||
`notify_on_json` text,
|
||||
`last_run_at` text,
|
||||
`last_status` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1780724800966,
|
||||
"tag": "0006_many_northstar",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1780766513336,
|
||||
"tag": "0007_bizarre_doctor_faustus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -319,3 +319,20 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
value: text("value"),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// Automatisations planifiées (cron) : analyse/metrics/scan sur un périmètre de machines.
|
||||
export const schedules = sqliteTable("schedules", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
enabled: integer("enabled").notNull().default(1),
|
||||
cron: text("cron").notNull(),
|
||||
timezone: text("timezone"),
|
||||
scopeJson: text("scope_json").notNull(), // {"machineIds":"all"|string[]}
|
||||
actionsJson: text("actions_json").notNull(), // ["apt_update_analyze","machine_metrics_simple",...]
|
||||
concurrency: integer("concurrency").notNull().default(2),
|
||||
notifyOnJson: text("notify_on_json"),
|
||||
lastRunAt: text("last_run_at"),
|
||||
lastStatus: text("last_status"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
});
|
||||
|
||||
+4
-17
@@ -1,24 +1,11 @@
|
||||
// server/jobs/worker.ts
|
||||
import { Cron } from "croner";
|
||||
import { listMachines } from "../services/machines.js";
|
||||
import { refreshMachine } from "../services/refresh.js";
|
||||
import { reloadSchedules, stopSchedules } from "../services/scheduler.js";
|
||||
|
||||
let job: Cron | null = null;
|
||||
|
||||
/** Rafraîchit toutes les machines toutes les 30 minutes (tâche de fond). */
|
||||
/** Démarre le planificateur : enregistre les automatisations actives (cron) depuis la BDD. */
|
||||
export function startWorker(): void {
|
||||
job = new Cron("*/30 * * * *", async () => {
|
||||
for (const m of listMachines()) {
|
||||
try {
|
||||
await refreshMachine(m.id);
|
||||
} catch (err) {
|
||||
console.error(`[worker] refresh échoué pour ${m.id}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
});
|
||||
reloadSchedules();
|
||||
}
|
||||
|
||||
export function stopWorker(): void {
|
||||
job?.stop();
|
||||
job = null;
|
||||
stopSchedules();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dockerRoutes } from "./docker.js";
|
||||
import { dbRoutes } from "./db.js";
|
||||
import { settingsRoutes } from "./settings.js";
|
||||
import { postInstallRoutes } from "./postInstall.js";
|
||||
import { schedulesRoutes } from "./schedules.js";
|
||||
import { getServerCapabilities } from "../services/capabilities.js";
|
||||
import { getSystemMetrics, getSystemStatus } from "../services/system.js";
|
||||
|
||||
@@ -16,6 +17,7 @@ api.get("/system/status", (c) => c.json(getSystemStatus()));
|
||||
api.get("/system/metrics", (c) => c.json(getSystemMetrics()));
|
||||
api.route("/system/db", dbRoutes);
|
||||
api.route("/settings", settingsRoutes);
|
||||
api.route("/schedules", schedulesRoutes);
|
||||
api.route("/machines", machinesRoutes);
|
||||
api.route("/machines", actionsRoutes);
|
||||
api.route("/machines", dockerRoutes);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// server/routes/schedules.ts
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
runSchedule,
|
||||
type ScheduleInput,
|
||||
} from "../services/scheduler.js";
|
||||
|
||||
export const schedulesRoutes = new Hono();
|
||||
|
||||
schedulesRoutes.get("/", (c) => c.json(listSchedules()));
|
||||
|
||||
schedulesRoutes.post("/", async (c) => {
|
||||
const body = (await c.req.json()) as ScheduleInput;
|
||||
try {
|
||||
return c.json(createSchedule(body), 201);
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
schedulesRoutes.get("/:id", (c) => {
|
||||
const s = getSchedule(c.req.param("id"));
|
||||
return s ? c.json(s) : c.json({ error: "Schedule introuvable" }, 404);
|
||||
});
|
||||
|
||||
schedulesRoutes.patch("/:id", async (c) => {
|
||||
const body = (await c.req.json()) as Partial<ScheduleInput>;
|
||||
try {
|
||||
return c.json(updateSchedule(c.req.param("id"), body));
|
||||
} catch (err) {
|
||||
return c.json({ error: (err as Error).message }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
schedulesRoutes.delete("/:id", (c) => {
|
||||
deleteSchedule(c.req.param("id"));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// Lancement immédiat (hors planning).
|
||||
schedulesRoutes.post("/:id/run", (c) => {
|
||||
runSchedule(c.req.param("id")).catch((err) => console.error("[schedule run]", (err as Error).message));
|
||||
return c.json({ ok: true }, 202);
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
// server/services/scheduler.ts
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Cron } from "croner";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, schema } from "../db/client.js";
|
||||
import { listMachines } from "./machines.js";
|
||||
import { refreshMachine } from "./refresh.js";
|
||||
import { collectMetrics } from "./machineMetrics.js";
|
||||
import { recordEvent } from "./machineState.js";
|
||||
|
||||
export type ScheduleAction = "apt_update_analyze" | "machine_metrics_simple" | "docker_scan";
|
||||
|
||||
export interface ScheduleScope {
|
||||
machineIds: "all" | string[];
|
||||
}
|
||||
|
||||
export interface ScheduleView {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
timezone: string | null;
|
||||
scope: ScheduleScope;
|
||||
actions: ScheduleAction[];
|
||||
concurrency: number;
|
||||
lastRunAt: string | null;
|
||||
lastStatus: string | null;
|
||||
}
|
||||
|
||||
type ScheduleRow = typeof schema.schedules.$inferSelect;
|
||||
|
||||
function toView(r: ScheduleRow): ScheduleView {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
enabled: !!r.enabled,
|
||||
cron: r.cron,
|
||||
timezone: r.timezone,
|
||||
scope: JSON.parse(r.scopeJson) as ScheduleScope,
|
||||
actions: JSON.parse(r.actionsJson) as ScheduleAction[],
|
||||
concurrency: r.concurrency,
|
||||
lastRunAt: r.lastRunAt,
|
||||
lastStatus: r.lastStatus,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function listSchedules(): ScheduleView[] {
|
||||
return db.select().from(schema.schedules).all().map(toView);
|
||||
}
|
||||
|
||||
export function getSchedule(id: string): ScheduleView | null {
|
||||
const r = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
|
||||
return r ? toView(r) : null;
|
||||
}
|
||||
|
||||
export interface ScheduleInput {
|
||||
name: string;
|
||||
cron: string;
|
||||
timezone?: string | null;
|
||||
enabled?: boolean;
|
||||
scope?: ScheduleScope;
|
||||
actions: ScheduleAction[];
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
export function createSchedule(input: ScheduleInput): ScheduleView {
|
||||
// Valide l'expression cron (lève si invalide), sans planifier.
|
||||
new Cron(input.cron).stop();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.insert(schema.schedules).values({
|
||||
id,
|
||||
name: input.name,
|
||||
enabled: input.enabled === false ? 0 : 1,
|
||||
cron: input.cron,
|
||||
timezone: input.timezone ?? "Europe/Paris",
|
||||
scopeJson: JSON.stringify(input.scope ?? { machineIds: "all" }),
|
||||
actionsJson: JSON.stringify(input.actions),
|
||||
concurrency: input.concurrency ?? 2,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
reloadSchedules();
|
||||
return getSchedule(id)!;
|
||||
}
|
||||
|
||||
export function updateSchedule(id: string, input: Partial<ScheduleInput>): ScheduleView {
|
||||
const cur = db.select().from(schema.schedules).where(eq(schema.schedules.id, id)).get();
|
||||
if (!cur) throw new Error("Schedule introuvable");
|
||||
if (input.cron) new Cron(input.cron).stop(); // valide sans planifier
|
||||
db.update(schema.schedules).set({
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled ? 1 : 0 } : {}),
|
||||
...(input.cron !== undefined ? { cron: input.cron } : {}),
|
||||
...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
|
||||
...(input.scope !== undefined ? { scopeJson: JSON.stringify(input.scope) } : {}),
|
||||
...(input.actions !== undefined ? { actionsJson: JSON.stringify(input.actions) } : {}),
|
||||
...(input.concurrency !== undefined ? { concurrency: input.concurrency } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.schedules.id, id)).run();
|
||||
reloadSchedules();
|
||||
return getSchedule(id)!;
|
||||
}
|
||||
|
||||
export function deleteSchedule(id: string): void {
|
||||
db.delete(schema.schedules).where(eq(schema.schedules.id, id)).run();
|
||||
reloadSchedules();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exécution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locked = new Set<string>();
|
||||
|
||||
function resolveMachineIds(scope: ScheduleScope): string[] {
|
||||
const all = listMachines().map((m) => m.id);
|
||||
return scope.machineIds === "all" ? all : scope.machineIds.filter((id) => all.includes(id));
|
||||
}
|
||||
|
||||
async function runActionOnMachine(machineId: string, action: ScheduleAction): Promise<void> {
|
||||
if (action === "apt_update_analyze") {
|
||||
await refreshMachine(machineId);
|
||||
} else if (action === "machine_metrics_simple") {
|
||||
await collectMetrics(machineId);
|
||||
} else if (action === "docker_scan") {
|
||||
const { scanDockerStacks } = await import("./dockerScan.js");
|
||||
await scanDockerStacks(machineId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exécute un schedule : actions sur le périmètre, avec verrou par machine et concurrence. */
|
||||
export async function runSchedule(id: string): Promise<{ ran: number; errors: number }> {
|
||||
const sched = getSchedule(id);
|
||||
if (!sched) throw new Error("Schedule introuvable");
|
||||
const machineIds = resolveMachineIds(sched.scope);
|
||||
let ran = 0;
|
||||
let errors = 0;
|
||||
|
||||
const queue = [...machineIds];
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const machineId = queue.shift();
|
||||
if (!machineId) break;
|
||||
if (locked.has(machineId)) continue; // une action tourne déjà sur cette machine
|
||||
locked.add(machineId);
|
||||
try {
|
||||
for (const action of sched.actions) {
|
||||
await runActionOnMachine(machineId, action);
|
||||
}
|
||||
ran++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
recordEvent({
|
||||
machineId,
|
||||
eventType: "schedule_action_failed",
|
||||
severity: "warning",
|
||||
message: `Schedule « ${sched.name} » : ${(err as Error).message}`,
|
||||
});
|
||||
} finally {
|
||||
locked.delete(machineId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pool = Math.max(1, Math.min(sched.concurrency, machineIds.length || 1));
|
||||
await Promise.all(Array.from({ length: pool }, () => worker()));
|
||||
|
||||
db.update(schema.schedules)
|
||||
.set({ lastRunAt: new Date().toISOString(), lastStatus: errors ? `partial (${errors} err)` : "ok" })
|
||||
.where(eq(schema.schedules.id, id))
|
||||
.run();
|
||||
return { ran, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enregistrement croner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let jobs: Cron[] = [];
|
||||
|
||||
export function reloadSchedules(): void {
|
||||
for (const j of jobs) j.stop();
|
||||
jobs = [];
|
||||
for (const s of listSchedules()) {
|
||||
if (!s.enabled) continue;
|
||||
try {
|
||||
jobs.push(
|
||||
new Cron(s.cron, { timezone: s.timezone ?? undefined, name: s.id }, () => {
|
||||
runSchedule(s.id).catch((err) => console.error(`[scheduler] ${s.name}:`, (err as Error).message));
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`[scheduler] cron invalide pour ${s.name}:`, (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopSchedules(): void {
|
||||
for (const j of jobs) j.stop();
|
||||
jobs = [];
|
||||
}
|
||||
Reference in New Issue
Block a user