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