// maclustr-www — site vitrine www.maclustr.io (v2, multi-pages) // Serveur Node sans dépendance : fichiers statiques, shell SPA (index.html) pour les routes, // /api/live et /api/history = proxys filtrés vers maclustr-agentd (jamais d'IP, d'utilisateur ni de jeton exposés). // Usage : node server.mjs [port] (env : AGENTD_URL, AGENTD_TOKEN, LIVE_TTL_S) import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.argv[2] || process.env.PORT || 8280); const PUBLIC = path.join(__dirname, "public"); const AGENTD_URL = (process.env.AGENTD_URL || "http://127.0.0.1:9210").replace(/\/$/, ""); const AGENTD_TOKEN = process.env.AGENTD_TOKEN || ""; const TTL_MS = Number(process.env.LIVE_TTL_S || 30) * 1000; const MIME = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2", ".xml": "application/xml", }; // ---------- agentd ---------- async function agentd(pathname) { const ctl = new AbortController(); const t = setTimeout(() => ctl.abort(), 12000); try { const r = await fetch(AGENTD_URL + pathname, { headers: { Authorization: `Bearer ${AGENTD_TOKEN}` }, signal: ctl.signal }); if (!r.ok) throw new Error(`agentd ${pathname} → ${r.status}`); return await r.json(); } finally { clearTimeout(t); } } const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null); function shape(cluster, apps, tunnel) { const nodes = {}; const metrics = cluster?.metrics || {}; for (const n of cluster?.nodes || []) { const m = metrics[n.name] || {}; const online = m.status === "online"; nodes[n.name] = { online, cpu: online ? round(m.cpu, 1) : null, gpu: online ? round(m.gpu, 0) : null, load1: online && Array.isArray(m.load) ? round(m.load[0], 2) : null, memUsedGB: online ? round((m.memUsedMB || 0) / 1024, 1) : null, memTotalGB: online ? round((m.memTotalMB || n.memoryMB || 0) / 1024, 0) : null, swapGB: online ? round((m.swapUsedMB || 0) / 1024, 1) : null, diskUsedGB: online ? round(m.diskUsedGB, 0) : null, diskTotalGB: online ? round(m.diskTotalGB, 0) : null, netInKBs: online ? round(m.netInKBs, 0) : null, netOutKBs: online ? round(m.netOutKBs, 0) : null, procs: online ? m.procs : null, uptimeS: online ? m.uptime : null, os: online ? m.os : null, thermal: online ? m.thermal : null, apps: cluster?.appsByNode?.[n.name] || null, }; } const appOut = {}; const list = Array.isArray(apps?.apps) ? apps.apps : Object.values(apps?.apps || {}); for (const a of list) { appOut[a.app] = { node: a.node, state: a.state, publicOk: a.public?.ok ?? null, publicMs: a.public?.ms ?? null, localOk: a.local?.ok ?? null, localMs: a.local?.ms ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null, memMB: a.memMB ?? null, cpu: a.cpu ?? null, }; } const gws = (tunnel?.gateways || []).map(g => ({ name: g.name, ok: !!g.ok, primary: !!g.primary, site: g.site, peers: (g.wg?.peers || []).map(p => ({ alias: p.alias, online: !!p.online, handshakeS: p.handshakeS ?? null, rx: p.rxBytes ?? 0, tx: p.txBytes ?? 0 })), routes: Array.isArray(g.routes) ? g.routes.filter(r => r.kind === "proxy").length : null, })); const onlineNodes = (cluster?.nodes || []).filter(n => nodes[n.name]?.online); const cpuVals = onlineNodes.map(n => nodes[n.name].cpu).filter(v => v != null); return { ts: Date.now(), agentVersion: cluster?.agentVersion || null, summary: { nodesTotal: Object.keys(nodes).length, nodesOnline: onlineNodes.length, apps: apps?.summary || null, coresOnline: onlineNodes.reduce((s, n) => s + (n.cpuCores || 0), 0), gpuOnline: onlineNodes.reduce((s, n) => s + (n.gpuCores || 0), 0), ramOnlineGB: round(onlineNodes.reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0), 0), ramUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.memUsedGB || 0), 0), 0), cpuAvg: cpuVals.length ? round(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length, 1) : null, diskUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskUsedGB || 0), 0), 0), diskTotalGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskTotalGB || 0), 0), 0), netInKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netInKBs || 0), 0), 0), netOutKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netOutKBs || 0), 0), 0), procs: Object.values(nodes).reduce((s, n) => s + (n.procs || 0), 0), tunnelRx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.rx, 0), 0), tunnelTx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.tx, 0), 0), }, nodes, apps: appOut, tunnel: gws, }; } // cache générique const caches = new Map(); async function cached(key, ttl, fn) { const c = caches.get(key) || {}; if (c.body && Date.now() - c.ts < ttl) return c.body; if (c.inflight) return c.inflight; c.inflight = (async () => { try { c.body = await fn(); c.ts = Date.now(); return c.body; } catch (e) { if (c.body) return { ...c.body, stale: true, error: String(e.message || e) }; throw e; } finally { c.inflight = null; } })(); caches.set(key, c); return c.inflight; } const live = () => cached("live", TTL_MS, async () => { const [cluster, apps, tunnel] = await Promise.all([agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null)]); return shape(cluster, apps, tunnel); }); const history = (node, win) => cached(`hist:${node}:${win}`, 60000, async () => { const d = await agentd(`/api/history?node=${encodeURIComponent(node)}&window=${win}`); return { node, window: win, points: (d.points || []).map(p => ({ ts: p.ts, cpu: round(p.cpu, 1), mem: round(p.mem, 1), gpu: round(p.gpu, 0), netIn: round(p.netIn, 0), netOut: round(p.netOut, 0) })) }; }); // ---------- titres par route (SEO minimal) ---------- const ROUTE_TITLES = [ [/^\/$/, "MacLustr — Le cluster Apple Silicon"], [/^\/nodes\/?$/, "Nœuds — MacLustr"], [/^\/nodes\/([A-Za-z0-9]+)/, (m) => `${m[1]} — nœud MacLustr`], [/^\/apps\/?$/, "Applications hébergées — MacLustr"], [/^\/map\/?$/, "Carte des nœuds — MacLustr"], [/^\/contact\/?$/, "Contact — MacLustr"], ]; let shellCache = null; function shell(pathname) { if (!shellCache) shellCache = fs.readFileSync(path.join(PUBLIC, "index.html"), "utf8"); let title = "MacLustr"; for (const [re, t] of ROUTE_TITLES) { const m = pathname.match(re); if (m) { title = typeof t === "function" ? t(m) : t; break; } } return shellCache.replace(/