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:
@@ -1,6 +1,6 @@
|
|||||||
// client/src/features/machines/MachineTile.tsx
|
// client/src/features/machines/MachineTile.tsx
|
||||||
import { useEffect, useState } from "react";
|
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 { Button, Icon, IconButton, Popup, StatusLed } from "../../components/ui-kit.js";
|
||||||
import {
|
import {
|
||||||
api,
|
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 }) {
|
function HardwareSection({ machineId }: { machineId: string }) {
|
||||||
const [hw, setHw] = useState<MachineHardwareView | null>(null);
|
const [hw, setHw] = useState<MachineHardwareView | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<MachineMetricsSimple | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
setHw(await api.machineHardware(machineId));
|
const [h, m] = await Promise.all([api.machineHardware(machineId), api.latestMetrics(machineId)]);
|
||||||
|
setHw(h);
|
||||||
|
setMetrics(m);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [machineId]);
|
}, [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 (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>;
|
if (!hw) return <div className="machine-section-body"><span className="machine-placeholder">Chargement…</span></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="machine-section-body">
|
<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">
|
<div className="machine-detail-card">
|
||||||
<InfoRow k="OS" v={`${hw.osFamily}${hw.osVersion ? ` ${hw.osVersion}` : ""}`} />
|
<InfoRow k="OS" v={`${hw.osFamily}${hw.osVersion ? ` ${hw.osVersion}` : ""}`} />
|
||||||
<InfoRow k="Type" v={hw.machineKind ?? "—"} />
|
<InfoRow k="Type" v={hw.machineKind ?? "—"} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// client/src/lib/api.ts
|
// 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> {
|
async function readJsonBody(res: Response): Promise<unknown> {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
@@ -60,6 +60,8 @@ export const api = {
|
|||||||
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
req<MachineView>(`/machines/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||||
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
probe: (id: string) => req<ProbeResultView>(`/machines/${id}/probe`, { method: "POST" }),
|
||||||
machineHardware: (id: string) => req<MachineHardwareView>(`/machines/${id}/hardware`),
|
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 ---
|
// --- Docker ---
|
||||||
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
dockerSettings: (id: string) => req<DockerSettingsView>(`/machines/${id}/docker/settings`),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "../services/machines.js";
|
} from "../services/machines.js";
|
||||||
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
import { refreshMachine, getLatestSnapshot } from "../services/refresh.js";
|
||||||
import { runProbe } from "../services/machineProbe.js";
|
import { runProbe } from "../services/machineProbe.js";
|
||||||
|
import { collectMetrics, getLatestMetrics } from "../services/machineMetrics.js";
|
||||||
|
|
||||||
export const machinesRoutes = new Hono();
|
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) => {
|
machinesRoutes.get("/:id/hardware", (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json(getMachineHardware(c.req.param("id")));
|
return c.json(getMachineHardware(c.req.param("id")));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -208,6 +208,14 @@ export interface RebootResult {
|
|||||||
errors?: SnapshotError[];
|
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 {
|
export interface PostInstallResult {
|
||||||
profilesRun: string[];
|
profilesRun: string[];
|
||||||
variablesUsed: Record<string, string | number | boolean>;
|
variablesUsed: Record<string, string | number | boolean>;
|
||||||
|
|||||||
@@ -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==="
|
||||||
Reference in New Issue
Block a user