spb/maclustr-appmanager
Public
HTML 61.4%
JavaScript 24.5%
Shell 14.1%
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