From c390addadb49943dc3104496ef52fefbdab224eb Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sat, 6 Jun 2026 17:01:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(metrics):=20machine=5Fmetrics=5Fsimple=20?= =?UTF-8?q?=E2=80=94=20CPU/RAM/disque=20live=20par=20machine=20(t=C3=A2che?= =?UTF-8?q?=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - template machine-metrics (loadavg/nproc, /proc/meminfo, df -B1) non destructif - parseMetrics (TDD) → cpu load/cores, mémoire kB→B + %, filesystems, warnings >=90% - collectMetrics (SSH léger) persiste machine_metrics_latest ; getLatestMetrics (sans SSH) - routes GET /machines/:id/metrics + POST /metrics/collect ; api latestMetrics/collectMetrics - section Hardware : bloc métriques live (CPU/RAM/disques + alertes) + bouton Collecter → comble le gap « Health » de la tâche 3 tsc 0 · 108 tests · build OK. Co-Authored-By: Claude Opus 4.8 --- client/src/features/machines/MachineTile.tsx | 47 ++++++- client/src/lib/api.ts | 4 +- server/routes/machines.ts | 13 ++ server/services/machineMetrics.test.ts | 49 ++++++++ server/services/machineMetrics.ts | 126 +++++++++++++++++++ shared/types.ts | 8 ++ templates/apt/machine-metrics.sh.tpl | 12 ++ 7 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 server/services/machineMetrics.test.ts create mode 100644 server/services/machineMetrics.ts create mode 100644 templates/apt/machine-metrics.sh.tpl diff --git a/client/src/features/machines/MachineTile.tsx b/client/src/features/machines/MachineTile.tsx index 151b1aa..031446c 100644 --- a/client/src/features/machines/MachineTile.tsx +++ b/client/src/features/machines/MachineTile.tsx @@ -1,6 +1,6 @@ // client/src/features/machines/MachineTile.tsx import { useEffect, useState } from "react"; -import type { ActionType, AptProxyMode, MachineStatus, MachineView } from "@shared/types.js"; +import type { ActionType, AptProxyMode, MachineMetricsSimple, MachineStatus, MachineView } from "@shared/types.js"; import { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js"; import { api, @@ -786,24 +786,67 @@ function PostInstallSection({ machine, onSelect }: { machine: MachineView; onSel ); } +function fmtBytes(b: number | null): string { + if (b === null) return "—"; + if (b >= 1e9) return `${(b / 1e9).toFixed(1)} Go`; + if (b >= 1e6) return `${(b / 1e6).toFixed(0)} Mo`; + return `${b} o`; +} + function HardwareSection({ machineId }: { machineId: string }) { const [hw, setHw] = useState(null); + const [metrics, setMetrics] = useState(null); const [err, setErr] = useState(null); + const [busy, setBusy] = useState(false); + useEffect(() => { void (async () => { try { - setHw(await api.machineHardware(machineId)); + const [h, m] = await Promise.all([api.machineHardware(machineId), api.latestMetrics(machineId)]); + setHw(h); + setMetrics(m); } catch (e) { setErr((e as Error).message); } })(); }, [machineId]); + const collect = async () => { + setBusy(true); + setErr(null); + try { + setMetrics(await api.collectMetrics(machineId)); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + if (err) return
{err}
; if (!hw) return
Chargement…
; return (
+
+
+ Métriques (CPU / RAM / disque) + +
+ {metrics ? ( + <> + + = 90 ? "warn" : undefined} /> + {metrics.filesystems.map((fs) => ( + = 90 ? "warn" : undefined} /> + ))} + {metrics.warnings.map((w, i) => {w})} + + ) : ( + Aucune métrique. Clique sur « Collecter ». + )} +
+
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 112430d..da4ac61 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1,5 +1,5 @@ // client/src/lib/api.ts -import type { ActionType, AptProxyMode, MachineKind, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; +import type { ActionType, AptProxyMode, MachineKind, MachineMetricsSimple, MachineView, OsFamily, SystemMetrics, UpdateSnapshot } from "@shared/types.js"; async function readJsonBody(res: Response): Promise { const text = await res.text(); @@ -60,6 +60,8 @@ export const api = { req(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }), probe: (id: string) => req(`/machines/${id}/probe`, { method: "POST" }), machineHardware: (id: string) => req(`/machines/${id}/hardware`), + latestMetrics: (id: string) => req(`/machines/${id}/metrics`), + collectMetrics: (id: string) => req(`/machines/${id}/metrics/collect`, { method: "POST" }), // --- Docker --- dockerSettings: (id: string) => req(`/machines/${id}/docker/settings`), diff --git a/server/routes/machines.ts b/server/routes/machines.ts index 4e5495c..70fa1b1 100644 --- a/server/routes/machines.ts +++ b/server/routes/machines.ts @@ -7,6 +7,7 @@ import { } from "../services/machines.js"; import { refreshMachine, getLatestSnapshot } from "../services/refresh.js"; import { runProbe } from "../services/machineProbe.js"; +import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js"; export const machinesRoutes = new Hono(); @@ -54,6 +55,18 @@ machinesRoutes.patch("/:id", async (c) => { } }); +// Dernières métriques stockées (sans SSH). +machinesRoutes.get("/:id/metrics", (c) => c.json(getLatestMetrics(c.req.param("id")))); + +// Collecte fraîche (SSH léger, non destructif). +machinesRoutes.post("/:id/metrics/collect", async (c) => { + try { + return c.json(await collectMetrics(c.req.param("id"))); + } catch (err) { + return c.json({ error: (err as Error).message }, 400); + } +}); + machinesRoutes.get("/:id/hardware", (c) => { try { return c.json(getMachineHardware(c.req.param("id"))); diff --git a/server/services/machineMetrics.test.ts b/server/services/machineMetrics.test.ts new file mode 100644 index 0000000..80bec9c --- /dev/null +++ b/server/services/machineMetrics.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { parseMetrics } from "./machineMetrics.js"; + +const RAW = [ + "===SU:METRICS_CPU===", + "0.08 0.12 0.09 1/234 5678", + "4", + "===SU:METRICS_MEM===", + "MemTotal: 4194304 kB", + "MemAvailable: 2097152 kB", + "===SU:METRICS_FS===", + "FS\t/\text4\t32000000000\t9280000000\t29%", + "FS\t/boot\text2\t500000000\t475000000\t95%", + "===SU:EXIT=0===", +].join("\n"); + +describe("parseMetrics", () => { + it("lit load average et cores", () => { + const m = parseMetrics(RAW); + expect(m.cpu.load1).toBe(0.08); + expect(m.cpu.load5).toBe(0.12); + expect(m.cpu.cores).toBe(4); + }); + + it("calcule la mémoire en octets (kB→B) et le pourcentage utilisé", () => { + const m = parseMetrics(RAW); + expect(m.memory.totalBytes).toBe(4194304 * 1024); + expect(m.memory.availableBytes).toBe(2097152 * 1024); + expect(m.memory.usedBytes).toBe((4194304 - 2097152) * 1024); + expect(m.memory.usedPercent).toBe(50); + }); + + it("liste les systèmes de fichiers", () => { + const m = parseMetrics(RAW); + expect(m.filesystems).toHaveLength(2); + expect(m.filesystems[0]).toEqual({ + mount: "/", + fstype: "ext4", + sizeBytes: 32000000000, + usedBytes: 9280000000, + usedPercent: 29, + }); + }); + + it("émet un warning pour un FS quasi plein (>=90%)", () => { + const m = parseMetrics(RAW); + expect(m.warnings.some((w) => w.includes("/boot"))).toBe(true); + }); +}); diff --git a/server/services/machineMetrics.ts b/server/services/machineMetrics.ts new file mode 100644 index 0000000..c28a874 --- /dev/null +++ b/server/services/machineMetrics.ts @@ -0,0 +1,126 @@ +// server/services/machineMetrics.ts +import { eq } from "drizzle-orm"; +import { db, schema } from "../db/client.js"; +import { getMachineRow, getCreds } from "./machines.js"; +import { renderTemplate } from "../templates/render.js"; +import { runScriptSudo } from "../ssh/client.js"; +import type { MachineMetricsSimple } from "@shared/types.js"; + +function section(raw: string, start: string, end?: string): string { + const i = raw.indexOf(start); + if (i < 0) return ""; + const from = i + start.length; + const j = end ? raw.indexOf(end, from) : -1; + return raw.slice(from, j < 0 ? undefined : j).trim(); +} + +const num = (s: string | undefined): number | null => { + if (s === undefined) return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +}; + +export function parseMetrics(raw: string): MachineMetricsSimple { + const cpuLines = section(raw, "===SU:METRICS_CPU===", "===SU:METRICS_MEM===").split("\n").filter(Boolean); + const loadParts = (cpuLines[0] ?? "").trim().split(/\s+/); + const cpu = { + load1: num(loadParts[0]), + load5: num(loadParts[1]), + cores: num((cpuLines[1] ?? "").trim()), + }; + + const memBlock = section(raw, "===SU:METRICS_MEM===", "===SU:METRICS_FS==="); + const memKb = (key: string): number | null => { + const m = new RegExp(`^${key}:\\s+(\\d+)\\s*kB`, "m").exec(memBlock); + return m?.[1] ? Number(m[1]) * 1024 : null; + }; + const totalBytes = memKb("MemTotal"); + const availableBytes = memKb("MemAvailable"); + const usedBytes = totalBytes !== null && availableBytes !== null ? totalBytes - availableBytes : null; + const usedPercent = totalBytes && usedBytes !== null ? Math.round((usedBytes / totalBytes) * 100) : null; + const memory = { totalBytes, usedBytes, availableBytes, usedPercent }; + + const filesystems: MachineMetricsSimple["filesystems"] = []; + for (const line of section(raw, "===SU:METRICS_FS===", "===SU:EXIT=").split("\n")) { + if (!line.startsWith("FS\t")) continue; + const [, mount, fstype, size, used, pcent] = line.split("\t"); + filesystems.push({ + mount: mount ?? "", + fstype: fstype ?? "", + sizeBytes: Number(size) || 0, + usedBytes: Number(used) || 0, + usedPercent: Number((pcent ?? "").replace("%", "")) || 0, + }); + } + + const warnings: string[] = []; + for (const fs of filesystems) { + if (fs.usedPercent >= 90) warnings.push(`Disque ${fs.mount} à ${fs.usedPercent}%`); + } + if (usedPercent !== null && usedPercent >= 90) warnings.push(`Mémoire à ${usedPercent}%`); + + return { collectedAt: new Date().toISOString(), cpu, memory, filesystems, warnings }; +} + +/** Collecte les métriques d'une machine via SSH et persiste machine_metrics_latest. */ +export async function collectMetrics(machineId: string): Promise { + const m = getMachineRow(machineId); + if (!m) throw new Error("Machine introuvable"); + const script = renderTemplate("apt/machine-metrics.sh.tpl", {}); + const res = await runScriptSudo(getCreds(m), script, () => {}); + const metrics = parseMetrics(res.stdout); + + const root = metrics.filesystems.find((f) => f.mount === "/"); + db.insert(schema.machineMetricsLatest) + .values({ + machineId, + collectedAt: metrics.collectedAt, + cpuLoad1: metrics.cpu.load1, + cpuLoad5: metrics.cpu.load5, + cpuCores: metrics.cpu.cores, + memoryTotalBytes: metrics.memory.totalBytes, + memoryUsedBytes: metrics.memory.usedBytes, + memoryAvailableBytes: metrics.memory.availableBytes, + memoryUsedPercent: metrics.memory.usedPercent, + filesystemsJson: JSON.stringify(metrics.filesystems), + rootUsedPercent: root?.usedPercent ?? null, + warningsJson: JSON.stringify(metrics.warnings), + }) + .onConflictDoUpdate({ + target: schema.machineMetricsLatest.machineId, + set: { + collectedAt: metrics.collectedAt, + cpuLoad1: metrics.cpu.load1, + cpuLoad5: metrics.cpu.load5, + cpuCores: metrics.cpu.cores, + memoryTotalBytes: metrics.memory.totalBytes, + memoryUsedBytes: metrics.memory.usedBytes, + memoryAvailableBytes: metrics.memory.availableBytes, + memoryUsedPercent: metrics.memory.usedPercent, + filesystemsJson: JSON.stringify(metrics.filesystems), + rootUsedPercent: root?.usedPercent ?? null, + warningsJson: JSON.stringify(metrics.warnings), + }, + }) + .run(); + + return metrics; +} + +/** Dernières métriques stockées (sans SSH), si présentes. */ +export function getLatestMetrics(machineId: string): MachineMetricsSimple | null { + const row = db.select().from(schema.machineMetricsLatest).where(eq(schema.machineMetricsLatest.machineId, machineId)).get(); + if (!row) return null; + return { + collectedAt: row.collectedAt, + cpu: { load1: row.cpuLoad1, load5: row.cpuLoad5, cores: row.cpuCores }, + memory: { + totalBytes: row.memoryTotalBytes, + usedBytes: row.memoryUsedBytes, + availableBytes: row.memoryAvailableBytes, + usedPercent: row.memoryUsedPercent, + }, + filesystems: row.filesystemsJson ? JSON.parse(row.filesystemsJson) : [], + warnings: row.warningsJson ? JSON.parse(row.warningsJson) : [], + }; +} diff --git a/shared/types.ts b/shared/types.ts index ec23490..ccff3d4 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -208,6 +208,14 @@ export interface RebootResult { errors?: SnapshotError[]; } +export interface MachineMetricsSimple { + collectedAt: string; + cpu: { load1: number | null; load5: number | null; cores: number | null }; + memory: { totalBytes: number | null; usedBytes: number | null; availableBytes: number | null; usedPercent: number | null }; + filesystems: { mount: string; fstype: string; sizeBytes: number; usedBytes: number; usedPercent: number }[]; + warnings: string[]; +} + export interface PostInstallResult { profilesRun: string[]; variablesUsed: Record; diff --git a/templates/apt/machine-metrics.sh.tpl b/templates/apt/machine-metrics.sh.tpl new file mode 100644 index 0000000..e8dd904 --- /dev/null +++ b/templates/apt/machine-metrics.sh.tpl @@ -0,0 +1,12 @@ +#!/bin/sh +# Métriques légères CPU/RAM/disque. Non destructif, rapide, sans installation. +export LC_ALL=C +echo "===SU:METRICS_CPU===" +cat /proc/loadavg 2>/dev/null +nproc 2>/dev/null +echo "===SU:METRICS_MEM===" +grep -E '^(MemTotal|MemAvailable):' /proc/meminfo 2>/dev/null +echo "===SU:METRICS_FS===" +df -B1 -T -x tmpfs -x devtmpfs -x overlay -x squashfs 2>/dev/null \ + | awk 'NR>1 {print "FS\t"$7"\t"$2"\t"$3"\t"$4"\t"$6}' +echo "===SU:EXIT=0==="