spb/maclustr-www
Public
JavaScript 58%
CSS 24.8%
HTML 17.1%
1// maclustr-www — site vitrine www.maclustr.io (v2, multi-pages)2// Serveur Node sans dépendance : fichiers statiques, shell SPA (index.html) pour les routes,3// /api/live et /api/history = proxys filtrés vers maclustr-agentd (jamais d'IP, d'utilisateur ni de jeton exposés).4// Usage : node server.mjs [port] (env : AGENTD_URL, AGENTD_TOKEN, LIVE_TTL_S)56import http from "node:http";7import fs from "node:fs";8import path from "node:path";9import { fileURLToPath } from "node:url";1011const __dirname = path.dirname(fileURLToPath(import.meta.url));12const PORT = Number(process.argv[2] || process.env.PORT || 8280);13const PUBLIC = path.join(__dirname, "public");14const AGENTD_URL = (process.env.AGENTD_URL || "http://127.0.0.1:9210").replace(/\/$/, "");15const AGENTD_TOKEN = process.env.AGENTD_TOKEN || "";16const TTL_MS = Number(process.env.LIVE_TTL_S || 30) * 1000;1718const MIME = {19 ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",20 ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",21 ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2", ".xml": "application/xml",22};2324// ---------- agentd ----------25async function agentd(pathname) {26 const ctl = new AbortController();27 const t = setTimeout(() => ctl.abort(), 12000);28 try {29 const r = await fetch(AGENTD_URL + pathname, { headers: { Authorization: `Bearer ${AGENTD_TOKEN}` }, signal: ctl.signal });30 if (!r.ok) throw new Error(`agentd ${pathname} → ${r.status}`);31 return await r.json();32 } finally { clearTimeout(t); }33}34const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null);3536function shape(cluster, apps, tunnel) {37 const nodes = {};38 const metrics = cluster?.metrics || {};39 for (const n of cluster?.nodes || []) {40 const m = metrics[n.name] || {};41 const online = m.status === "online";42 nodes[n.name] = {43 online,44 cpu: online ? round(m.cpu, 1) : null,45 gpu: online ? round(m.gpu, 0) : null,46 load1: online && Array.isArray(m.load) ? round(m.load[0], 2) : null,47 memUsedGB: online ? round((m.memUsedMB || 0) / 1024, 1) : null,48 memTotalGB: online ? round((m.memTotalMB || n.memoryMB || 0) / 1024, 0) : null,49 swapGB: online ? round((m.swapUsedMB || 0) / 1024, 1) : null,50 diskUsedGB: online ? round(m.diskUsedGB, 0) : null,51 diskTotalGB: online ? round(m.diskTotalGB, 0) : null,52 netInKBs: online ? round(m.netInKBs, 0) : null,53 netOutKBs: online ? round(m.netOutKBs, 0) : null,54 procs: online ? m.procs : null,55 uptimeS: online ? m.uptime : null,56 os: online ? m.os : null,57 thermal: online ? m.thermal : null,58 apps: cluster?.appsByNode?.[n.name] || null,59 };60 }61 const appOut = {};62 const list = Array.isArray(apps?.apps) ? apps.apps : Object.values(apps?.apps || {});63 for (const a of list) {64 appOut[a.app] = {65 node: a.node, state: a.state, publicOk: a.public?.ok ?? null, publicMs: a.public?.ms ?? null,66 localOk: a.local?.ok ?? null, localMs: a.local?.ms ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null,67 memMB: a.memMB ?? null, cpu: a.cpu ?? null,68 };69 }70 const gws = (tunnel?.gateways || []).map(g => ({71 name: g.name, ok: !!g.ok, primary: !!g.primary, site: g.site,72 peers: (g.wg?.peers || []).map(p => ({ alias: p.alias, online: !!p.online, handshakeS: p.handshakeS ?? null, rx: p.rxBytes ?? 0, tx: p.txBytes ?? 0 })),73 routes: Array.isArray(g.routes) ? g.routes.filter(r => r.kind === "proxy").length : null,74 }));75 const onlineNodes = (cluster?.nodes || []).filter(n => nodes[n.name]?.online);76 const cpuVals = onlineNodes.map(n => nodes[n.name].cpu).filter(v => v != null);77 return {78 ts: Date.now(), agentVersion: cluster?.agentVersion || null,79 summary: {80 nodesTotal: Object.keys(nodes).length, nodesOnline: onlineNodes.length,81 apps: apps?.summary || null,82 coresOnline: onlineNodes.reduce((s, n) => s + (n.cpuCores || 0), 0),83 gpuOnline: onlineNodes.reduce((s, n) => s + (n.gpuCores || 0), 0),84 ramOnlineGB: round(onlineNodes.reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0), 0),85 ramUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.memUsedGB || 0), 0), 0),86 cpuAvg: cpuVals.length ? round(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length, 1) : null,87 diskUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskUsedGB || 0), 0), 0),88 diskTotalGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskTotalGB || 0), 0), 0),89 netInKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netInKBs || 0), 0), 0),90 netOutKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netOutKBs || 0), 0), 0),91 procs: Object.values(nodes).reduce((s, n) => s + (n.procs || 0), 0),92 tunnelRx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.rx, 0), 0),93 tunnelTx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.tx, 0), 0),94 },95 nodes, apps: appOut, tunnel: gws,96 };97}9899// cache générique100const caches = new Map();101async function cached(key, ttl, fn) {102 const c = caches.get(key) || {};103 if (c.body && Date.now() - c.ts < ttl) return c.body;104 if (c.inflight) return c.inflight;105 c.inflight = (async () => {106 try { c.body = await fn(); c.ts = Date.now(); return c.body; }107 catch (e) { if (c.body) return { ...c.body, stale: true, error: String(e.message || e) }; throw e; }108 finally { c.inflight = null; }109 })();110 caches.set(key, c);111 return c.inflight;112}113const live = () => cached("live", TTL_MS, async () => {114 const [cluster, apps, tunnel] = await Promise.all([agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null)]);115 return shape(cluster, apps, tunnel);116});117const history = (node, win) => cached(`hist:${node}:${win}`, 60000, async () => {118 const d = await agentd(`/api/history?node=${encodeURIComponent(node)}&window=${win}`);119 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) })) };120});121122// ---------- titres par route (SEO minimal) ----------123const ROUTE_TITLES = [124 [/^\/$/, "MacLustr — Le cluster Apple Silicon"],125 [/^\/nodes\/?$/, "Nœuds — MacLustr"],126 [/^\/nodes\/([A-Za-z0-9]+)/, (m) => `${m[1]} — nœud MacLustr`],127 [/^\/apps\/?$/, "Applications hébergées — MacLustr"],128 [/^\/map\/?$/, "Carte des nœuds — MacLustr"],129 [/^\/contact\/?$/, "Contact — MacLustr"],130];131let shellCache = null;132function shell(pathname) {133 if (!shellCache) shellCache = fs.readFileSync(path.join(PUBLIC, "index.html"), "utf8");134 let title = "MacLustr";135 for (const [re, t] of ROUTE_TITLES) { const m = pathname.match(re); if (m) { title = typeof t === "function" ? t(m) : t; break; } }136 return shellCache.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);137}138if (process.env.NODE_ENV !== "production") fs.watch(PUBLIC, () => { shellCache = null; });139140// ---------- http ----------141const server = http.createServer(async (req, res) => {142 const url = new URL(req.url, "http://x");143 res.setHeader("X-Content-Type-Options", "nosniff");144 res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");145146 try {147 if (url.pathname === "/healthz") return send(res, 200, "ok\n", "text/plain");148 if (url.pathname === "/api/live") {149 res.setHeader("Cache-Control", "public, max-age=15");150 try { return send(res, 200, JSON.stringify(await live()), MIME[".json"]); }151 catch (e) { return send(res, 503, JSON.stringify({ ts: Date.now(), error: String(e.message || e), nodes: {}, apps: {}, tunnel: [], summary: null }), MIME[".json"]); }152 }153 if (url.pathname === "/api/history") {154 const node = (url.searchParams.get("node") || "").replace(/[^A-Za-z0-9]/g, "");155 const win = ["1h", "6h", "24h"].includes(url.searchParams.get("window")) ? url.searchParams.get("window") : "24h";156 if (!node) return send(res, 400, JSON.stringify({ error: "node requis" }), MIME[".json"]);157 res.setHeader("Cache-Control", "public, max-age=60");158 try { return send(res, 200, JSON.stringify(await history(node, win)), MIME[".json"]); }159 catch (e) { return send(res, 503, JSON.stringify({ node, window: win, points: [], error: String(e.message || e) }), MIME[".json"]); }160 }161 if (url.pathname === "/robots.txt") return send(res, 200, "User-agent: *\nAllow: /\nSitemap: https://www.maclustr.io/sitemap.xml\n", "text/plain");162 if (url.pathname === "/sitemap.xml") {163 const ids = sitemapIds();164 const urls = ["/", "/nodes", "/apps", "/map", "/contact", ...ids.map(i => `/nodes/${i}`)];165 return send(res, 200, `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map(u => ` <url><loc>https://www.maclustr.io${u}</loc></url>`).join("\n")}\n</urlset>\n`, MIME[".xml"]);166 }167168 let p = decodeURIComponent(url.pathname);169 const file = path.normalize(path.join(PUBLIC, p));170 if (!file.startsWith(PUBLIC)) return send(res, 403, "forbidden", "text/plain");171 const isAsset = path.extname(p) !== "";172 if (!isAsset) { res.setHeader("Cache-Control", "no-cache"); return send(res, 200, shell(p), MIME[".html"]); }173 fs.stat(file, (err, st) => {174 if (err || !st.isFile()) return send(res, 404, "not found", "text/plain");175 const ext = path.extname(file).toLowerCase();176 res.setHeader("Cache-Control", ext === ".html" ? "no-cache" : "public, max-age=600");177 res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": st.size });178 fs.createReadStream(file).pipe(res);179 });180 } catch (e) {181 send(res, 500, "error", "text/plain");182 }183});184function sitemapIds() {185 try { const s = fs.readFileSync(path.join(PUBLIC, "data.js"), "utf8"); return [...s.matchAll(/\{ id:"([A-Za-z0-9]+)", group:"(?:lan|rented|ovh)"/g)].map(m => m[1]); } catch { return []; }186}187function send(res, code, body, type) { res.writeHead(code, { "Content-Type": type }); res.end(body); }188189server.listen(PORT, "0.0.0.0", () => console.log(`maclustr-www v2 :${PORT} → agentd ${AGENTD_URL} (token ${AGENTD_TOKEN ? "ok" : "ABSENT"})`));190