SPB Git forge
2commits 1branches 0releases
492.0 KBsize
maindefault branch
4 h agolast push
TypeScript 96.1% CSS 3.1% JavaScript 0.8%
6.2 KB · 153 lines typescript
Raw Blame History
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