feat(metrics): machine_metrics_simple — CPU/RAM/disque live par machine (tâche 4)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 17:01:45 +02:00
parent 58abebf687
commit c390addadb
7 changed files with 256 additions and 3 deletions
+45 -2
View File
@@ -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<MachineHardwareView | null>(null);
const [metrics, setMetrics] = useState<MachineMetricsSimple | null>(null);
const [err, setErr] = useState<string | null>(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 <div className="machine-section-body"><span className="docker-msg docker-msg-err">{err}</span></div>;
if (!hw) return <div className="machine-section-body"><span className="machine-placeholder">Chargement</span></div>;
return (
<div className="machine-section-body">
<div className="machine-detail-card">
<div className="cfg-block-head">
<span className="label">Métriques (CPU / RAM / disque)</span>
<Button icon="refresh" size="sm" onClick={busy ? undefined : collect}>{busy ? "…" : "Collecter"}</Button>
</div>
{metrics ? (
<>
<InfoRow k="CPU load" v={`${metrics.cpu.load1 ?? "—"} / ${metrics.cpu.cores ?? "?"}c`} mono />
<InfoRow k="RAM" v={`${metrics.memory.usedPercent ?? "—"}% · ${fmtBytes(metrics.memory.usedBytes)} / ${fmtBytes(metrics.memory.totalBytes)}`} mono tone={(metrics.memory.usedPercent ?? 0) >= 90 ? "warn" : undefined} />
{metrics.filesystems.map((fs) => (
<InfoRow key={fs.mount} k={fs.mount} v={`${fs.usedPercent}% · ${fmtBytes(fs.usedBytes)} / ${fmtBytes(fs.sizeBytes)}`} mono tone={fs.usedPercent >= 90 ? "warn" : undefined} />
))}
{metrics.warnings.map((w, i) => <span key={i} className="docker-msg docker-msg-err">{w}</span>)}
</>
) : (
<span className="machine-placeholder">Aucune métrique. Clique sur « Collecter ».</span>
)}
</div>
<div className="machine-detail-card">
<InfoRow k="OS" v={`${hw.osFamily}${hw.osVersion ? ` ${hw.osVersion}` : ""}`} />
<InfoRow k="Type" v={hw.machineKind ?? "—"} />
+3 -1
View File
@@ -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<unknown> {
const text = await res.text();
@@ -60,6 +60,8 @@ export const api = {
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
latestMetrics: (id: string) => req<MachineMetricsSimple | null>(`/machines/${id}/metrics`),
collectMetrics: (id: string) => req<MachineMetricsSimple>(`/machines/${id}/metrics/collect`, { method: "POST" }),
// --- Docker ---
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
+13
View File
@@ -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")));
+49
View File
@@ -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);
});
});
+126
View File
@@ -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<MachineMetricsSimple> {
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) : [],
};
}
+8
View File
@@ -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<string, string | number | boolean>;
+12
View File
@@ -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==="