c390addadb
- 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>
127 lines
5.0 KiB
TypeScript
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) : [],
|
|
};
|
|
}
|