// maclustr app-manager — dependency-free Node server. // Serves the dashboard + /api/cluster (live SSH fan-out across all nodes). import http from "node:http"; import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const DIR = dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || "8787", 10); const COLLECTOR = readFileSync(join(DIR, "collector.sh"), "utf8"); const HTML = readFileSync(join(DIR, "index.html"), "utf8"); // Parse the collector's line protocol into the JSON shape the UI expects. function parseCollector(text) { const o = { ports: [], tunnels: [], agents: [], load: [] }; for (const line of text.split("\n")) { const sp = line.indexOf(" "); if (sp < 0) continue; const tag = line.slice(0, sp), rest = line.slice(sp + 1); if (tag === "HOST") o.hostname = rest.trim(); else if (tag === "CORES") o.cores = parseInt(rest) || null; else if (tag === "MEMTOTAL") o.mem_total = parseInt(rest) || 0; else if (tag === "MEMUSED") o.mem_used = parseInt(rest) || 0; else if (tag === "LOAD") o.load = rest.trim().split(/\s+/).map(Number).filter(x => !isNaN(x)).slice(0, 3); else if (tag === "BOOT") o.boot_epoch = parseInt(rest) || null; 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 || ""; } else if (tag === "PORT") { const [port, proc, lb, cwd] = rest.split("\t"); o.ports.push({ port: parseInt(port), proc, loopback: lb === "1", cwd: cwd || "" }); } else if (tag === "TUN") { const [url, addr] = rest.split("\t"); o.tunnels.push({ url, addr: addr || "" }); } 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 }); } } o.ports.sort((a, b) => a.port - b.port); return o; } // alias -> display model (cores/ram are read live from each node) const NODES = [ ["M3U96a","Mac Studio"], ["M3U96b","Mac Studio"], ["M2U64","Mac Studio"], ["M4M64a","Mac Studio"], ["M4M64b","Mac Studio"], ["M4M36","Mac Studio"], ["M2M32","Mac Studio"], ["M2M32b","Mac Studio"], ["M2M32c","Mac Studio"], ["M1M32","Mac Studio"], ["M4BP48","MacBook Pro"], ["M4BP36","MacBook Pro"], ["M3BA24","MacBook Air"], ["M3BA16","MacBook Air"], ["m4mc","Mac mini"], ["m4ma","Mac mini"], ["m4mb","Mac mini"], ["m2m16","Mac mini"], ["m2m8a","Mac mini"], ["m2m8b","Mac mini"], ]; const SSH_OPTS = [ "-o","BatchMode=yes","-o","StrictHostKeyChecking=accept-new", "-o","ConnectTimeout=6","-o","ServerAliveInterval=3","-o","ServerAliveCountMax=2", ]; function collectNode([alias, model]) { return new Promise((resolve) => { let out = "", err = "", done = false; const finish = (o) => { if (!done) { done = true; resolve(o); } }; const env = { ...process.env }; delete env.SSH_AUTH_SOCK; // use M2U64's own key, not a forwarded agent const p = spawn("ssh", [...SSH_OPTS, alias, "sh"], { env }); const kill = setTimeout(() => { try { p.kill("SIGKILL"); } catch {} finish({ alias, meta: { model }, reachable: false, error: "timeout (>18s)" }); }, 18000); p.stdout.on("data", d => out += d); p.stderr.on("data", d => err += d); p.on("error", e => { clearTimeout(kill); finish({ alias, meta:{model}, reachable:false, error:String(e.message||e).slice(0,160) }); }); p.on("close", () => { clearTimeout(kill); if (out.indexOf("HOST ") < 0) return finish({ alias, meta:{model}, reachable:false, error:(err.trim()||"no output").slice(0,160) }); const data = parseCollector(out); finish({ alias, meta:{model}, reachable: !!data.hostname, ...data }); }); try { p.stdin.write(COLLECTOR); p.stdin.end(); } catch {} }); } // bounded-concurrency fan-out async function collectAll(limit = 10) { const t0 = Date.now(); const queue = [...NODES]; const results = []; async function worker() { while (queue.length) { const n = queue.shift(); results.push(await collectNode(n)); } } await Promise.all(Array.from({ length: Math.min(limit, NODES.length) }, worker)); return { generatedAt: Date.now(), tookMs: Date.now() - t0, nodes: results }; } // cache + single-flight let cache = null, cacheTs = 0, inflight = null; const TTL = 12000; async function getCluster(force = false) { const now = Date.now(); if (!force && cache && now - cacheTs < TTL) return cache; if (inflight) return inflight; inflight = collectAll().then(r => { cache = r; cacheTs = Date.now(); inflight = null; return r; }) .catch(e => { inflight = null; throw e; }); return inflight; } const server = http.createServer(async (req, res) => { const url = (req.url || "/").split("?")[0]; try { if (url === "/" || url === "/index.html") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); return res.end(HTML); } if (url === "/healthz") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ ok: true, nodes: NODES.length, cachedAt: cacheTs })); } if (url === "/api/cluster") { const data = await getCluster(false); res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); return res.end(JSON.stringify(data)); } res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String(e && e.message || e) })); } }); server.listen(PORT, "0.0.0.0", () => { console.log(`[maclustr-app-manager] listening on :${PORT} — ${NODES.length} nodes`); getCluster(true).then(r => console.log(`[startup] collected ${r.nodes.filter(n=>n.reachable).length}/${r.nodes.length} in ${r.tookMs}ms`)).catch(()=>{}); }); // background warm refresh setInterval(() => { getCluster(true).catch(()=>{}); }, 20000);