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
@@ -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
+7
View File
@@ -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
}
]
}
+17
View File
@@ -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
View File
@@ -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();
}
+2
View File
@@ -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);
+49
View File
@@ -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);
});
+206
View File
@@ -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 = [];
}