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
|
||||
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 ?? "—"} />
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
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>;
|
||||
|
||||
@@ -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