spb/maclustr-console
Public
TypeScript 96.1%
CSS 3.1%
JavaScript 0.8%
1import { Client } from "ssh2";2import { readFileSync } from "fs";34const SSH_USER = process.env.SSH_USER || "simon-pierreboucher";5const SSH_KEY_PATH = process.env.SSH_KEY_PATH || `${process.env.HOME}/.ssh/id_ed25519`;67export function executeSSH(host: string, command: string, timeoutMs = 10000): Promise<string> {8 return new Promise((resolve, reject) => {9 const conn = new Client();10 let output = "";11 const timer = setTimeout(() => {12 conn.end();13 reject(new Error("SSH timeout"));14 }, timeoutMs);1516 conn17 .on("ready", () => {18 conn.exec(command, (err, stream) => {19 if (err) {20 clearTimeout(timer);21 conn.end();22 reject(err);23 return;24 }25 stream.on("data", (data: Buffer) => { output += data.toString(); });26 stream.stderr.on("data", (data: Buffer) => { output += data.toString(); });27 stream.on("close", () => {28 clearTimeout(timer);29 conn.end();30 resolve(output.trim());31 });32 });33 })34 .on("error", (err) => {35 clearTimeout(timer);36 reject(err);37 })38 .connect({39 host,40 port: 22,41 username: SSH_USER,42 privateKey: readFileSync(SSH_KEY_PATH),43 hostVerifier: () => true,44 } as Record<string, unknown>);45 });46}4748export interface NodeMetrics {49 nodeId: string;50 online: boolean;51 cpuUsage: number;52 memoryUsed: number;53 memoryTotal: number;54 diskUsed: number; // internal SSD, bytes55 diskTotal: number; // internal SSD, bytes56 extDiskUsed: number; // external drives (sum of /Volumes/*), bytes57 extDiskTotal: number; // external drives, bytes58 loadAverage: number[];59 uptime: number;60 processes: number;61 thermalState: string;62 gpuUsage: number;63 networkIn: number; // KB/s64 networkOut: number; // KB/s65}6667const METRICS_COMMAND = `68IFACE=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}')69echo "CPU:$(top -l 1 -n 0 2>/dev/null | awk '/CPU usage/{gsub(/%/,""); print 100 - $7}' || echo 0)"70echo "MEM_TOTAL:$(sysctl -n hw.memsize 2>/dev/null || echo 0)"71echo "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)"72echo "DISK:$(df -k / 2>/dev/null | tail -1 | awk '{print $3,$2}')"73echo "DISK_EXT:$(df -k 2>/dev/null | awk '$9 ~ "^/Volumes/" {u+=$3; t+=$2} END{printf "%d %d", u+0, t+0}')"74echo "LOAD:$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' || echo '0 0 0')"75echo "UPTIME:$(sysctl -n kern.boottime 2>/dev/null | awk -F'sec = ' '{print $2}' | awk -F',' '{print $1}')"76echo "PROCS:$(ps aux 2>/dev/null | wc -l | tr -d ' ')"77echo "THERMAL:$(pmset -g therm 2>/dev/null | awk '/CPU_Scheduler_Limit/{print $3}' || echo 100)"78echo "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)"79echo "NET:$(netstat -ibn -I \${IFACE:-en0} 2>/dev/null | grep -v Link | tail -1 | awk '{print $7, $10}' || echo '0 0')"80`.trim();8182// Per-node previous network counters, to derive a KB/s rate between polls.83const netPrev = new Map<string, { in: number; out: number; t: number }>();8485export async function collectMetrics(nodeId: string, hostname: string): Promise<NodeMetrics> {86 const defaults: NodeMetrics = {87 nodeId, online: false, cpuUsage: 0,88 memoryUsed: 0, memoryTotal: 0,89 diskUsed: 0, diskTotal: 0,90 extDiskUsed: 0, extDiskTotal: 0,91 loadAverage: [0, 0, 0], uptime: 0,92 processes: 0, thermalState: "nominal",93 gpuUsage: -1, networkIn: 0, networkOut: 0,94 };9596 try {97 const raw = await executeSSH(hostname, METRICS_COMMAND);98 const lines = raw.split("\n");99 const get = (prefix: string) => lines.find(l => l.startsWith(prefix))?.slice(prefix.length) || "";100101 const cpuRaw = parseFloat(get("CPU:")) || 0;102 const memTotal = parseInt(get("MEM_TOTAL:")) || 0;103 // Match the macOS app exactly: memory "pressure" = (active+wired+compressor) /104 // (active+wired+compressor+free+inactive+speculative), used = total * pressure.105 const memPress = parseFloat(get("MEM_PRESS:")) || 0;106 const memUsed = Math.round(memTotal * (memPress / 100));107 const diskParts = get("DISK:").split(/\s+/);108 const diskUsed = parseInt(diskParts[0] || "0") * 1024;109 const diskTotal = parseInt(diskParts[1] || "0") * 1024;110 const extParts = get("DISK_EXT:").trim().split(/\s+/);111 const extDiskUsed = parseInt(extParts[0] || "0") * 1024;112 const extDiskTotal = parseInt(extParts[1] || "0") * 1024;113 const loadParts = get("LOAD:").trim().split(/\s+/).map(Number);114 const bootTime = parseInt(get("UPTIME:")) || 0;115 const uptime = bootTime > 0 ? Math.floor(Date.now() / 1000) - bootTime : 0;116 const procs = parseInt(get("PROCS:")) || 0;117 const thermal = parseInt(get("THERMAL:")) || 100;118 const gpu = parseFloat(get("GPU:"));119 const gpuUsage = Number.isFinite(gpu) ? gpu : -1;120121 // Network: cumulative bytes -> KB/s rate using the previous sample.122 const netParts = get("NET:").trim().split(/\s+/).map(Number);123 let networkIn = 0, networkOut = 0;124 if (netParts.length >= 2 && Number.isFinite(netParts[0]) && Number.isFinite(netParts[1])) {125 const now = Date.now();126 const prev = netPrev.get(nodeId);127 if (prev && now > prev.t && netParts[0] >= prev.in && netParts[1] >= prev.out) {128 const dt = (now - prev.t) / 1000;129 networkIn = (netParts[0] - prev.in) / 1024 / dt;130 networkOut = (netParts[1] - prev.out) / 1024 / dt;131 }132 netPrev.set(nodeId, { in: netParts[0], out: netParts[1], t: now });133 }134135 return {136 nodeId, online: true,137 cpuUsage: cpuRaw,138 memoryUsed: memUsed,139 memoryTotal: memTotal,140 diskUsed, diskTotal,141 extDiskUsed, extDiskTotal,142 loadAverage: loadParts.length >= 3 ? loadParts.slice(0, 3) : [0, 0, 0],143 uptime, processes: procs,144 thermalState: thermal >= 100 ? "nominal" : thermal >= 70 ? "fair" : "serious",145 gpuUsage,146 networkIn: Math.round(networkIn),147 networkOut: Math.round(networkOut),148 };149 } catch {150 return defaults;151 }152}153