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 ?? "—"} />