SPB Git forge
1commits 1branches 0releases
28.0 KBsize
maindefault branch
1 h agolast push
HTML 61.4% JavaScript 24.5% Shell 14.1%
5.9 KB · 131 lines javascript
Raw Blame History
1// maclustr app-manager — dependency-free Node server.2// Serves the dashboard + /api/cluster (live SSH fan-out across all nodes).3import http from "node:http";4import { spawn } from "node:child_process";5import { readFileSync } from "node:fs";6import { fileURLToPath } from "node:url";7import { dirname, join } from "node:path";89const DIR = dirname(fileURLToPath(import.meta.url));10const PORT = parseInt(process.env.PORT || "8787", 10);11const COLLECTOR = readFileSync(join(DIR, "collector.sh"), "utf8");12const HTML = readFileSync(join(DIR, "index.html"), "utf8");1314// Parse the collector's line protocol into the JSON shape the UI expects.15function parseCollector(text) {16  const o = { ports: [], tunnels: [], agents: [], load: [] };17  for (const line of text.split("\n")) {18    const sp = line.indexOf(" ");19    if (sp < 0) continue;20    const tag = line.slice(0, sp), rest = line.slice(sp + 1);21    if (tag === "HOST") o.hostname = rest.trim();22    else if (tag === "CORES") o.cores = parseInt(rest) || null;23    else if (tag === "MEMTOTAL") o.mem_total = parseInt(rest) || 0;24    else if (tag === "MEMUSED") o.mem_used = parseInt(rest) || 0;25    else if (tag === "LOAD") o.load = rest.trim().split(/\s+/).map(Number).filter(x => !isNaN(x)).slice(0, 3);26    else if (tag === "BOOT") o.boot_epoch = parseInt(rest) || null;27    else if (tag === "DISK") { const [t, u, p] = rest.trim().split(/\s+/); o.disk_total = parseInt(t) || 0; o.disk_used = parseInt(u) || 0; o.disk_pct = p || ""; }28    else if (tag === "PORT") { const [port, proc, lb, cwd] = rest.split("\t"); o.ports.push({ port: parseInt(port), proc, loopback: lb === "1", cwd: cwd || "" }); }29    else if (tag === "TUN") { const [url, addr] = rest.split("\t"); o.tunnels.push({ url, addr: addr || "" }); }30    else if (tag === "AGENT") { const [label, scope, ld, run, pid] = rest.split("\t"); o.agents.push({ label, scope, loaded: ld === "1", running: run === "1", pid }); }31  }32  o.ports.sort((a, b) => a.port - b.port);33  return o;34}3536// alias -> display model (cores/ram are read live from each node)37const NODES = [38  ["M3U96a","Mac Studio"], ["M3U96b","Mac Studio"], ["M2U64","Mac Studio"],39  ["M4M64a","Mac Studio"], ["M4M64b","Mac Studio"], ["M4M36","Mac Studio"],40  ["M2M32","Mac Studio"], ["M2M32b","Mac Studio"], ["M2M32c","Mac Studio"],41  ["M1M32","Mac Studio"], ["M4BP48","MacBook Pro"], ["M4BP36","MacBook Pro"],42  ["M3BA24","MacBook Air"], ["M3BA16","MacBook Air"], ["m4mc","Mac mini"],43  ["m4ma","Mac mini"], ["m4mb","Mac mini"], ["m2m16","Mac mini"],44  ["m2m8a","Mac mini"], ["m2m8b","Mac mini"],45];4647const SSH_OPTS = [48  "-o","BatchMode=yes","-o","StrictHostKeyChecking=accept-new",49  "-o","ConnectTimeout=6","-o","ServerAliveInterval=3","-o","ServerAliveCountMax=2",50];5152function collectNode([alias, model]) {53  return new Promise((resolve) => {54    let out = "", err = "", done = false;55    const finish = (o) => { if (!done) { done = true; resolve(o); } };56    const env = { ...process.env };57    delete env.SSH_AUTH_SOCK; // use M2U64's own key, not a forwarded agent58    const p = spawn("ssh", [...SSH_OPTS, alias, "sh"], { env });59    const kill = setTimeout(() => { try { p.kill("SIGKILL"); } catch {}60      finish({ alias, meta: { model }, reachable: false, error: "timeout (>18s)" }); }, 18000);61    p.stdout.on("data", d => out += d);62    p.stderr.on("data", d => err += d);63    p.on("error", e => { clearTimeout(kill); finish({ alias, meta:{model}, reachable:false, error:String(e.message||e).slice(0,160) }); });64    p.on("close", () => {65      clearTimeout(kill);66      if (out.indexOf("HOST ") < 0) return finish({ alias, meta:{model}, reachable:false, error:(err.trim()||"no output").slice(0,160) });67      const data = parseCollector(out);68      finish({ alias, meta:{model}, reachable: !!data.hostname, ...data });69    });70    try { p.stdin.write(COLLECTOR); p.stdin.end(); } catch {}71  });72}7374// bounded-concurrency fan-out75async function collectAll(limit = 10) {76  const t0 = Date.now();77  const queue = [...NODES];78  const results = [];79  async function worker() {80    while (queue.length) {81      const n = queue.shift();82      results.push(await collectNode(n));83    }84  }85  await Promise.all(Array.from({ length: Math.min(limit, NODES.length) }, worker));86  return { generatedAt: Date.now(), tookMs: Date.now() - t0, nodes: results };87}8889// cache + single-flight90let cache = null, cacheTs = 0, inflight = null;91const TTL = 12000;92async function getCluster(force = false) {93  const now = Date.now();94  if (!force && cache && now - cacheTs < TTL) return cache;95  if (inflight) return inflight;96  inflight = collectAll().then(r => { cache = r; cacheTs = Date.now(); inflight = null; return r; })97                         .catch(e => { inflight = null; throw e; });98  return inflight;99}100101const server = http.createServer(async (req, res) => {102  const url = (req.url || "/").split("?")[0];103  try {104    if (url === "/" || url === "/index.html") {105      res.writeHead(200, { "content-type": "text/html; charset=utf-8" });106      return res.end(HTML);107    }108    if (url === "/healthz") {109      res.writeHead(200, { "content-type": "application/json" });110      return res.end(JSON.stringify({ ok: true, nodes: NODES.length, cachedAt: cacheTs }));111    }112    if (url === "/api/cluster") {113      const data = await getCluster(false);114      res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });115      return res.end(JSON.stringify(data));116    }117    res.writeHead(404, { "content-type": "text/plain" });118    res.end("not found");119  } catch (e) {120    res.writeHead(500, { "content-type": "application/json" });121    res.end(JSON.stringify({ error: String(e && e.message || e) }));122  }123});124125server.listen(PORT, "0.0.0.0", () => {126  console.log(`[maclustr-app-manager] listening on :${PORT} — ${NODES.length} nodes`);127  getCluster(true).then(r => console.log(`[startup] collected ${r.nodes.filter(n=>n.reachable).length}/${r.nodes.length} in ${r.tookMs}ms`)).catch(()=>{});128});129// background warm refresh130setInterval(() => { getCluster(true).catch(()=>{}); }, 20000);131