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:
@@ -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) : [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user