Files
system_update/server/services/machineMetrics.ts
T
gilles c390addadb 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>
2026-06-06 17:01:45 +02:00

127 lines
5.0 KiB
TypeScript

// 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) : [],
};
}