import { Client } from "ssh2"; import { readFileSync } from "fs"; const SSH_USER = process.env.SSH_USER || "simon-pierreboucher"; const SSH_KEY_PATH = process.env.SSH_KEY_PATH || `${process.env.HOME}/.ssh/id_ed25519`; export function executeSSH(host: string, command: string, timeoutMs = 10000): Promise { return new Promise((resolve, reject) => { const conn = new Client(); let output = ""; const timer = setTimeout(() => { conn.end(); reject(new Error("SSH timeout")); }, timeoutMs); conn .on("ready", () => { conn.exec(command, (err, stream) => { if (err) { clearTimeout(timer); conn.end(); reject(err); return; } stream.on("data", (data: Buffer) => { output += data.toString(); }); stream.stderr.on("data", (data: Buffer) => { output += data.toString(); }); stream.on("close", () => { clearTimeout(timer); conn.end(); resolve(output.trim()); }); }); }) .on("error", (err) => { clearTimeout(timer); reject(err); }) .connect({ host, port: 22, username: SSH_USER, privateKey: readFileSync(SSH_KEY_PATH), hostVerifier: () => true, } as Record); }); } export interface NodeMetrics { nodeId: string; online: boolean; cpuUsage: number; memoryUsed: number; memoryTotal: number; diskUsed: number; // internal SSD, bytes diskTotal: number; // internal SSD, bytes extDiskUsed: number; // external drives (sum of /Volumes/*), bytes extDiskTotal: number; // external drives, bytes loadAverage: number[]; uptime: number; processes: number; thermalState: string; gpuUsage: number; networkIn: number; // KB/s networkOut: number; // KB/s } const METRICS_COMMAND = ` IFACE=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}') echo "CPU:$(top -l 1 -n 0 2>/dev/null | awk '/CPU usage/{gsub(/%/,""); print 100 - $7}' || echo 0)" echo "MEM_TOTAL:$(sysctl -n hw.memsize 2>/dev/null || echo 0)" echo "MEM_PRESS:$(vm_stat 2>/dev/null | awk '/Pages active/{a=$3} /Pages wired down/{w=$4} /Pages occupied by compressor/{c=$5} /Pages free/{f=$3} /Pages inactive/{i=$3} /Pages speculative/{s=$3} END{u=a+w+c; t=u+f+i+s; if(t>0) printf "%.2f", u/t*100; else print 50}' || echo 0)" echo "DISK:$(df -k / 2>/dev/null | tail -1 | awk '{print $3,$2}')" echo "DISK_EXT:$(df -k 2>/dev/null | awk '$9 ~ "^/Volumes/" {u+=$3; t+=$2} END{printf "%d %d", u+0, t+0}')" echo "LOAD:$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' || echo '0 0 0')" echo "UPTIME:$(sysctl -n kern.boottime 2>/dev/null | awk -F'sec = ' '{print $2}' | awk -F',' '{print $1}')" echo "PROCS:$(ps aux 2>/dev/null | wc -l | tr -d ' ')" echo "THERMAL:$(pmset -g therm 2>/dev/null | awk '/CPU_Scheduler_Limit/{print $3}' || echo 100)" echo "GPU:$(ioreg -r -c IOAccelerator -d 1 2>/dev/null | grep -o '\"Device Utilization %\"=[0-9]*' | grep -o '[0-9]*$' | sort -rn | head -1 || echo -1)" echo "NET:$(netstat -ibn -I \${IFACE:-en0} 2>/dev/null | grep -v Link | tail -1 | awk '{print $7, $10}' || echo '0 0')" `.trim(); // Per-node previous network counters, to derive a KB/s rate between polls. const netPrev = new Map(); export async function collectMetrics(nodeId: string, hostname: string): Promise { const defaults: NodeMetrics = { nodeId, online: false, cpuUsage: 0, memoryUsed: 0, memoryTotal: 0, diskUsed: 0, diskTotal: 0, extDiskUsed: 0, extDiskTotal: 0, loadAverage: [0, 0, 0], uptime: 0, processes: 0, thermalState: "nominal", gpuUsage: -1, networkIn: 0, networkOut: 0, }; try { const raw = await executeSSH(hostname, METRICS_COMMAND); const lines = raw.split("\n"); const get = (prefix: string) => lines.find(l => l.startsWith(prefix))?.slice(prefix.length) || ""; const cpuRaw = parseFloat(get("CPU:")) || 0; const memTotal = parseInt(get("MEM_TOTAL:")) || 0; // Match the macOS app exactly: memory "pressure" = (active+wired+compressor) / // (active+wired+compressor+free+inactive+speculative), used = total * pressure. const memPress = parseFloat(get("MEM_PRESS:")) || 0; const memUsed = Math.round(memTotal * (memPress / 100)); const diskParts = get("DISK:").split(/\s+/); const diskUsed = parseInt(diskParts[0] || "0") * 1024; const diskTotal = parseInt(diskParts[1] || "0") * 1024; const extParts = get("DISK_EXT:").trim().split(/\s+/); const extDiskUsed = parseInt(extParts[0] || "0") * 1024; const extDiskTotal = parseInt(extParts[1] || "0") * 1024; const loadParts = get("LOAD:").trim().split(/\s+/).map(Number); const bootTime = parseInt(get("UPTIME:")) || 0; const uptime = bootTime > 0 ? Math.floor(Date.now() / 1000) - bootTime : 0; const procs = parseInt(get("PROCS:")) || 0; const thermal = parseInt(get("THERMAL:")) || 100; const gpu = parseFloat(get("GPU:")); const gpuUsage = Number.isFinite(gpu) ? gpu : -1; // Network: cumulative bytes -> KB/s rate using the previous sample. const netParts = get("NET:").trim().split(/\s+/).map(Number); let networkIn = 0, networkOut = 0; if (netParts.length >= 2 && Number.isFinite(netParts[0]) && Number.isFinite(netParts[1])) { const now = Date.now(); const prev = netPrev.get(nodeId); if (prev && now > prev.t && netParts[0] >= prev.in && netParts[1] >= prev.out) { const dt = (now - prev.t) / 1000; networkIn = (netParts[0] - prev.in) / 1024 / dt; networkOut = (netParts[1] - prev.out) / 1024 / dt; } netPrev.set(nodeId, { in: netParts[0], out: netParts[1], t: now }); } return { nodeId, online: true, cpuUsage: cpuRaw, memoryUsed: memUsed, memoryTotal: memTotal, diskUsed, diskTotal, extDiskUsed, extDiskTotal, loadAverage: loadParts.length >= 3 ? loadParts.slice(0, 3) : [0, 0, 0], uptime, processes: procs, thermalState: thermal >= 100 ? "nominal" : thermal >= 70 ? "fair" : "serious", gpuUsage, networkIn: Math.round(networkIn), networkOut: Math.round(networkOut), }; } catch { return defaults; } }