maclustr-www v1.0 — site vitrine www.maclustr.io (37 nœuds, 35 apps, live agentd)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
8 changed files +975 −0
added
.gitignore
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +node_modules/ | |
| 2 | +.DS_Store | |
added
README.md
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# maclustr-www — www.maclustr.io | |
| 2 | + | |
| 3 | +Site vitrine du cluster MacLustr : inventaire des 33 Macs + 4 serveurs OVHcloud, 35 applications hébergées, | |
| 4 | +architecture, MacLustr Tunnel. Mobile-first, sans dépendance. | |
| 5 | + | |
| 6 | +- `public/data.js` — inventaire statique (nœuds, apps, passerelles). **À mettre à jour à chaque ajout/retrait de machine ou d'app.** | |
| 7 | +- `public/index.html`, `styles.css`, `app.js` — front. | |
| 8 | +- `server.mjs` — serveur Node : statiques + `/api/live` (proxy filtré vers maclustr-agentd M4M64a:9210, cache 30 s ; ne laisse passer ni IP ni utilisateur). | |
| 9 | + | |
| 10 | +```bash | |
| 11 | +AGENTD_TOKEN=… node server.mjs 8280 # local | |
| 12 | +mld stage ~/Desktop/Cluster/maclustr-www maclustr-www && mld deploy maclustr-www | |
| 13 | +``` | |
| 14 | + | |
| 15 | +Manifeste mld : `M1M32:~/dispatch/apps/maclustr-www.json` (port 8280, tunnel www.maclustr.io → BHS64). | |
added
public/app.js
+259 −0
@@ -0,0 +1,259 @@ | ||
| 1 | +/* MacLustr — rendu de l'inventaire + superposition live (/api/live toutes les 30 s) */ | |
| 2 | +(function () { | |
| 3 | + const D = window.MACLUSTR; | |
| 4 | + const $ = (s, r = document) => r.querySelector(s); | |
| 5 | + const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; | |
| 6 | + const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| 7 | + const fmt = n => new Intl.NumberFormat("fr-CA").format(n); | |
| 8 | + const GROUP_LABEL = { lan: "LAN Saint-Augustin", rented: "Mac dédié loué", ovh: "OVHcloud (Linux)" }; | |
| 9 | + | |
| 10 | + let live = null; | |
| 11 | + let filter = "all"; | |
| 12 | + let sortKey = "cores"; | |
| 13 | + const appsByNode = {}; | |
| 14 | + D.apps.forEach(a => (appsByNode[a.node] ||= []).push(a)); | |
| 15 | + const appById = Object.fromEntries(D.apps.map(a => [a.id, a])); | |
| 16 | + | |
| 17 | + // ---------- KPIs ---------- | |
| 18 | + function renderKPIs() { | |
| 19 | + const macs = D.nodes.filter(n => n.group !== "ovh"); | |
| 20 | + const cores = D.nodes.reduce((s, n) => s + n.cores, 0); | |
| 21 | + const gpu = macs.reduce((s, n) => s + (n.gpu || 0), 0); | |
| 22 | + const ram = D.nodes.reduce((s, n) => s + n.ram, 0); | |
| 23 | + const set = (k, v) => { const e = $(`[data-kpi="${k}"]`); if (e) e.textContent = v; }; | |
| 24 | + set("nodes", D.nodes.length); | |
| 25 | + set("cores", fmt(cores)); | |
| 26 | + set("gpu", fmt(gpu)); | |
| 27 | + set("ram", ram >= 1000 ? (ram / 1024).toFixed(2).replace(".", ",") + " To" : ram + " Go"); | |
| 28 | + set("apps", D.apps.length); | |
| 29 | + set("sites", D.apps.filter(a => a.domain).length); | |
| 30 | + $("#nodeCountTitle").textContent = `${D.nodes.length} nœuds`; | |
| 31 | + $("#appCountTitle").textContent = `${D.apps.length} applications`; | |
| 32 | + ["all", "lan", "rented", "ovh"].forEach(g => { $(`#cnt-${g}`).textContent = g === "all" ? D.nodes.length : D.nodes.filter(n => n.group === g).length; }); | |
| 33 | + } | |
| 34 | + function patchKPIsLive() { | |
| 35 | + if (!live?.summary) return; | |
| 36 | + const s = live.summary; | |
| 37 | + const set = (k, v) => { const e = $(`[data-kpi="${k}"]`); if (e) e.textContent = v; }; | |
| 38 | + const macsOnline = s.nodesOnline, macsTotal = s.nodesTotal; | |
| 39 | + set("nodesSub", `${macsOnline}/${macsTotal} Macs en ligne · ${D.nodes.filter(n => n.group === "ovh").length} Linux`); | |
| 40 | + set("coresSub", `${fmt(s.coresOnline)} cœurs Mac en ligne`); | |
| 41 | + if (s.ramOnlineGB) set("ramSub", `${fmt(s.ramUsedGB)} Go utilisés sur ${fmt(Math.round(s.ramOnlineGB))} Go Mac en ligne`); | |
| 42 | + if (s.apps) set("appsSub", `${s.apps.up}/${s.apps.total} saines à l'instant`); | |
| 43 | + } | |
| 44 | + | |
| 45 | + // ---------- Nodes ---------- | |
| 46 | + function nodeCard(n) { | |
| 47 | + const c = el("article", `node tier-${n.tier}`); | |
| 48 | + c.dataset.id = n.id; c.dataset.group = n.group; | |
| 49 | + const disk = n.disk ? (n.disk >= 1000 ? (n.disk / 1000).toFixed(1).replace(".", ",") + " To" : n.disk + " Go") : "—"; | |
| 50 | + const apps = (appsByNode[n.id] || []).map(a => `<a href="#app-${a.id}" data-app="${a.id}">${esc(a.name)}</a>`).join(""); | |
| 51 | + const where = n.group === "lan" ? `<span>📍 Saint-Augustin</span><span><code>${esc(n.ip)}</code></span>` | |
| 52 | + : `<span>📍 ${esc(n.location || "")}</span><span>🏢 ${esc(n.provider || "")}</span>`; | |
| 53 | + c.innerHTML = ` | |
| 54 | + <div class="node-head"> | |
| 55 | + <div><div class="node-id">${esc(n.id)}</div><div class="node-model">${esc(n.model)}</div></div> | |
| 56 | + <span class="status na" data-status>—</span> | |
| 57 | + </div> | |
| 58 | + <div class="badges"> | |
| 59 | + <span class="badge chip-${n.tier}">${esc(n.chip)}</span> | |
| 60 | + <span class="badge">${GROUP_LABEL[n.group]}</span> | |
| 61 | + ${n.tags.filter(t => ["gateway", "réservé", "headless", "agents", "monitoring", "europe", "réserve"].includes(t)).map(t => `<span class="badge role">${esc(t)}</span>`).join("")} | |
| 62 | + </div> | |
| 63 | + <div class="specs"> | |
| 64 | + <div class="spec"><b>${n.cores}</b><span>${n.threads ? "threads" : "cœurs"}</span></div> | |
| 65 | + <div class="spec"><b>${n.gpu ? n.gpu : "—"}</b><span>GPU</span></div> | |
| 66 | + <div class="spec"><b>${n.ram}</b><span>Go RAM</span></div> | |
| 67 | + <div class="spec"><b>${disk}</b><span>disque</span></div> | |
| 68 | + </div> | |
| 69 | + ${n.group !== "ovh" ? ` | |
| 70 | + <div class="meters"> | |
| 71 | + <div class="meter na" data-m="cpu"><span>CPU</span><div class="bar"><i></i></div><span class="v">—</span></div> | |
| 72 | + <div class="meter na" data-m="ram"><span>RAM</span><div class="bar"><i></i></div><span class="v">—</span></div> | |
| 73 | + <div class="meter na" data-m="disk"><span>Disque</span><div class="bar"><i></i></div><span class="v">—</span></div> | |
| 74 | + </div>` : ""} | |
| 75 | + <div class="node-role">${esc(n.role)}</div> | |
| 76 | + <div class="node-desc">${esc(n.desc)}</div> | |
| 77 | + ${apps ? `<div class="node-apps">${apps}</div>` : ""} | |
| 78 | + <div class="node-foot">${where}<span data-extra></span></div>`; | |
| 79 | + return c; | |
| 80 | + } | |
| 81 | + | |
| 82 | + function liveSort(a, b) { | |
| 83 | + const la = live?.nodes?.[a.id], lb = live?.nodes?.[b.id]; | |
| 84 | + const va = la?.online ? la.cpu ?? -1 : -2, vb = lb?.online ? lb.cpu ?? -1 : -2; | |
| 85 | + return vb - va; | |
| 86 | + } | |
| 87 | + function sortedNodes() { | |
| 88 | + const list = D.nodes.filter(n => filter === "all" || n.group === filter); | |
| 89 | + const cmp = { cores: (a, b) => b.cores - a.cores || b.ram - a.ram, ram: (a, b) => b.ram - a.ram || b.cores - a.cores, name: (a, b) => a.id.localeCompare(b.id, "fr", { sensitivity: "base" }), load: liveSort }[sortKey]; | |
| 90 | + return list.sort(cmp); | |
| 91 | + } | |
| 92 | + function renderNodes() { | |
| 93 | + const host = $("#nodes"); host.innerHTML = ""; | |
| 94 | + sortedNodes().forEach(n => host.appendChild(nodeCard(n))); | |
| 95 | + renderGroupSummary(); | |
| 96 | + patchNodesLive(); | |
| 97 | + } | |
| 98 | + function renderGroupSummary() { | |
| 99 | + const g = $("#groupSummary"); | |
| 100 | + if (filter === "all") { g.classList.remove("show"); return; } | |
| 101 | + const list = D.nodes.filter(n => n.group === filter); | |
| 102 | + const cores = list.reduce((s, n) => s + n.cores, 0), ram = list.reduce((s, n) => s + n.ram, 0), gpu = list.reduce((s, n) => s + (n.gpu || 0), 0); | |
| 103 | + const desc = { lan: "Réseau local 192.168.2.x, domaine maclustr.io, clés SSH ed25519, découverte live des IP.", rented: "Macs dédiés loués chez Macly, MacStadium et rentamac. Hors LAN, joints par IP publique ou passerelle SSH, raccordés au tunnel. Réservés : déploiement seulement sur demande explicite.", ovh: "Serveurs bare metal Ubuntu 24.04 à Beauharnois (Québec) et Gravelines (France). Deux passerelles WireGuard + Caddy, deux serveurs de réserve." }[filter]; | |
| 104 | + g.innerHTML = `<span>${list.length} nœuds</span><span>${fmt(cores)} ${filter === "ovh" ? "threads" : "cœurs"}</span>${gpu ? `<span>${fmt(gpu)} cœurs GPU</span>` : ""}<span>${fmt(ram)} Go RAM</span><span style="border:0;background:none;padding-left:0">${desc}</span>`; | |
| 105 | + g.classList.add("show"); | |
| 106 | + } | |
| 107 | + const pct = (a, b) => (a != null && b) ? Math.min(100, Math.round(a / b * 100)) : null; | |
| 108 | + function setMeter(card, key, p, label) { | |
| 109 | + const m = card.querySelector(`[data-m="${key}"]`); if (!m) return; | |
| 110 | + const bar = m.querySelector("i"), v = m.querySelector(".v"); | |
| 111 | + if (p == null) { m.classList.add("na"); bar.style.width = "0"; v.textContent = "—"; return; } | |
| 112 | + m.classList.remove("na"); bar.style.width = p + "%"; bar.className = p >= 90 ? "crit" : p >= 75 ? "hi" : ""; v.textContent = label; | |
| 113 | + } | |
| 114 | + function uptime(s) { if (!s) return ""; const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600); return d ? `${d} j ${h} h` : `${h} h ${Math.floor(s % 3600 / 60)} min`; } | |
| 115 | + function patchNodesLive() { | |
| 116 | + document.querySelectorAll(".node").forEach(card => { | |
| 117 | + const n = D.nodes.find(x => x.id === card.dataset.id); | |
| 118 | + const st = card.querySelector("[data-status]"), extra = card.querySelector("[data-extra]"); | |
| 119 | + if (n.group === "ovh") { | |
| 120 | + const gw = live?.tunnel?.find(g => g.name === n.id); | |
| 121 | + if (gw) { st.className = `status ${gw.ok ? "on" : "off"}`; st.textContent = gw.ok ? "passerelle active" : "injoignable"; extra.textContent = gw.routes != null ? `${gw.routes} routes · ${gw.peers.filter(p => p.online).length} pairs` : ""; } | |
| 122 | + else { st.className = "status on"; st.textContent = n.tags.includes("gateway") ? "passerelle" : "en réserve"; } | |
| 123 | + return; | |
| 124 | + } | |
| 125 | + const l = live?.nodes?.[n.id]; | |
| 126 | + if (!live) { st.className = "status na"; st.textContent = "…"; return; } | |
| 127 | + if (!l || !l.online) { | |
| 128 | + st.className = "status off"; st.textContent = "hors ligne"; card.classList.add("is-off"); | |
| 129 | + ["cpu", "ram", "disk"].forEach(k => setMeter(card, k, null)); extra.textContent = ""; return; | |
| 130 | + } | |
| 131 | + card.classList.remove("is-off"); | |
| 132 | + st.className = "status on"; st.textContent = "en ligne"; | |
| 133 | + setMeter(card, "cpu", l.cpu != null ? Math.round(l.cpu) : null, `${Math.round(l.cpu)} %`); | |
| 134 | + setMeter(card, "ram", pct(l.memUsedGB, l.memTotalGB), `${l.memUsedGB} / ${l.memTotalGB} Go`); | |
| 135 | + setMeter(card, "disk", pct(l.diskUsedGB, l.diskTotalGB), `${fmt(l.diskUsedGB)} / ${fmt(l.diskTotalGB)} Go`); | |
| 136 | + const bits = []; | |
| 137 | + if (l.os) bits.push(`macOS ${l.os}`); | |
| 138 | + if (l.uptimeS) bits.push(`⏱ ${uptime(l.uptimeS)}`); | |
| 139 | + if (l.load1 != null) bits.push(`load ${l.load1}`); | |
| 140 | + extra.textContent = bits.join(" · "); | |
| 141 | + card.querySelectorAll(".node-apps a").forEach(a => { | |
| 142 | + const la = live.apps?.[a.dataset.app]; | |
| 143 | + a.classList.toggle("down", !!la && la.state !== "up"); | |
| 144 | + }); | |
| 145 | + }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + // ---------- Apps ---------- | |
| 149 | + function renderApps() { | |
| 150 | + const host = $("#appGroups"); host.innerHTML = ""; | |
| 151 | + D.appGroups.forEach(g => { | |
| 152 | + const apps = D.apps.filter(a => a.group === g.id); | |
| 153 | + const sec = el("div", "app-group"); | |
| 154 | + sec.innerHTML = `<div class="app-group-head"><h3>${esc(g.name)}</h3><span class="cnt">${apps.length} app${apps.length > 1 ? "s" : ""}</span><p>${esc(g.blurb)}</p></div>`; | |
| 155 | + const grid = el("div", "apps-grid"); | |
| 156 | + apps.forEach(a => { | |
| 157 | + const card = el(a.domain ? "a" : "div", "app"); | |
| 158 | + card.id = `app-${a.id}`; card.dataset.app = a.id; | |
| 159 | + if (a.domain) { card.href = `https://${a.domain}/`; card.target = "_blank"; card.rel = "noopener"; } | |
| 160 | + card.innerHTML = ` | |
| 161 | + <div class="app-top"><span class="app-name">${esc(a.name)}</span><span class="status na" data-status>—</span></div> | |
| 162 | + <div class="app-domain ${a.domain ? "" : "none"}">${a.domain ? esc(a.domain) : "service interne"}</div> | |
| 163 | + <p class="app-desc">${esc(a.desc)}</p> | |
| 164 | + <div class="app-meta"><span class="badge nodetag" data-node>${esc(a.node)}</span><span class="badge">:${a.port}</span><span class="ms" data-ms></span></div>`; | |
| 165 | + grid.appendChild(card); | |
| 166 | + }); | |
| 167 | + sec.appendChild(grid); host.appendChild(sec); | |
| 168 | + }); | |
| 169 | + patchAppsLive(); | |
| 170 | + } | |
| 171 | + function patchAppsLive() { | |
| 172 | + document.querySelectorAll(".app").forEach(card => { | |
| 173 | + const a = appById[card.dataset.app]; const l = live?.apps?.[a.id]; | |
| 174 | + const st = card.querySelector("[data-status]"), ms = card.querySelector("[data-ms]"), nd = card.querySelector("[data-node]"); | |
| 175 | + if (!live) return; | |
| 176 | + if (!l) { st.className = "status na"; st.textContent = "inconnu"; return; } | |
| 177 | + if (l.node) nd.textContent = l.node; | |
| 178 | + const up = l.state === "up"; | |
| 179 | + st.className = `status ${up ? "on" : l.state === "degraded" ? "na" : "off"}`; | |
| 180 | + st.textContent = up ? "en ligne" : l.state === "degraded" ? "dégradée" : l.state === "down" ? "hors service" : (l.state || "—"); | |
| 181 | + const parts = []; | |
| 182 | + if (l.publicMs != null && l.publicOk) parts.push(`${l.publicMs} ms public`); | |
| 183 | + if (l.uptime24h != null) parts.push(`${Math.round(l.uptime24h)} % / 24 h`); | |
| 184 | + if (l.via) parts.push(`via ${l.via}`); | |
| 185 | + ms.textContent = parts.join(" · "); | |
| 186 | + }); | |
| 187 | + } | |
| 188 | + | |
| 189 | + // ---------- Tunnel ---------- | |
| 190 | + function renderGateways() { | |
| 191 | + const host = $("#gateways"); host.innerHTML = ""; | |
| 192 | + D.tunnel.gateways.forEach(g => { | |
| 193 | + const n = D.nodes.find(x => x.id === g.id); | |
| 194 | + const c = el("div", `gw ${g.primary ? "primary" : ""}`); c.dataset.gw = g.id; | |
| 195 | + c.innerHTML = ` | |
| 196 | + <div class="gw-head"><h3>${esc(g.id)}</h3><span class="badge">${g.primary ? "passerelle principale" : "passerelle secondaire"}</span><span class="status na" data-status>—</span></div> | |
| 197 | + <div class="gw-meta">${esc(n?.chip || "")} · ${n?.ram} Go · ${esc(g.site)} · <code>${esc(g.ip)}</code> · WireGuard <code>${esc(g.wg)}</code></div> | |
| 198 | + <div class="gw-stats"> | |
| 199 | + <div class="spec"><b data-routes>—</b><span>routes HTTPS</span></div> | |
| 200 | + <div class="spec"><b data-peers>—</b><span>pairs en ligne</span></div> | |
| 201 | + <div class="spec"><b data-traffic>—</b><span>trafic sortant</span></div> | |
| 202 | + </div> | |
| 203 | + <div class="peers-title">Pairs WireGuard</div> | |
| 204 | + <div class="peers" data-peerlist><span class="peer">chargement…</span></div>`; | |
| 205 | + host.appendChild(c); | |
| 206 | + }); | |
| 207 | + patchGatewaysLive(); | |
| 208 | + } | |
| 209 | + const bytes = b => b >= 1e12 ? (b / 1e12).toFixed(1) + " To" : b >= 1e9 ? (b / 1e9).toFixed(1) + " Go" : (b / 1e6).toFixed(0) + " Mo"; | |
| 210 | + function patchGatewaysLive() { | |
| 211 | + if (!live?.tunnel) return; | |
| 212 | + document.querySelectorAll(".gw").forEach(c => { | |
| 213 | + const g = live.tunnel.find(x => x.name === c.dataset.gw); if (!g) return; | |
| 214 | + const st = c.querySelector("[data-status]"); | |
| 215 | + st.className = `status ${g.ok ? "on" : "off"}`; st.textContent = g.ok ? "active" : "injoignable"; | |
| 216 | + const on = g.peers.filter(p => p.online); | |
| 217 | + c.querySelector("[data-routes]").textContent = g.routes ?? "—"; | |
| 218 | + c.querySelector("[data-peers]").textContent = `${on.length}/${g.peers.length}`; | |
| 219 | + c.querySelector("[data-traffic]").textContent = bytes(g.peers.reduce((s, p) => s + (p.rx || 0), 0)); | |
| 220 | + c.querySelector("[data-peerlist]").innerHTML = g.peers.sort((a, b) => a.alias.localeCompare(b.alias)).map(p => `<span class="peer ${p.online ? "on" : ""}" title="${p.online ? "handshake il y a " + p.handshakeS + " s" : "pas de handshake récent"}">${esc(p.alias)}</span>`).join("") || "<span class='peer'>aucun pair</span>"; | |
| 221 | + }); | |
| 222 | + } | |
| 223 | + | |
| 224 | + // ---------- Live ---------- | |
| 225 | + async function refresh() { | |
| 226 | + const pill = $("#livePill"); | |
| 227 | + try { | |
| 228 | + const r = await fetch("/api/live", { cache: "no-store" }); | |
| 229 | + const j = await r.json(); | |
| 230 | + if (!j.summary) throw new Error(j.error || "sans données"); | |
| 231 | + live = j; | |
| 232 | + pill.className = "live-pill ok"; | |
| 233 | + pill.querySelector(".txt").textContent = `live · ${j.summary.nodesOnline}/${j.summary.nodesTotal} Macs · ${j.summary.apps ? j.summary.apps.up + "/" + j.summary.apps.total + " apps" : ""}`; | |
| 234 | + $("#footMeta").textContent = `Inventaire au ${new Date(D.updated).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" })} · état live ${new Date(j.ts).toLocaleTimeString("fr-CA")}${j.stale ? " (cache)" : ""} · agent ${j.agentVersion || ""}`; | |
| 235 | + patchKPIsLive(); patchNodesLive(); patchAppsLive(); patchGatewaysLive(); | |
| 236 | + if (sortKey === "load") renderNodes(); | |
| 237 | + } catch (e) { | |
| 238 | + pill.className = "live-pill bad"; | |
| 239 | + pill.querySelector(".txt").textContent = "live indisponible"; | |
| 240 | + if (!live) { live = { nodes: {}, apps: {}, tunnel: [], summary: null }; patchNodesLive(); live = null; } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + // ---------- UI ---------- | |
| 245 | + document.querySelectorAll(".chip[data-filter]").forEach(b => b.addEventListener("click", () => { | |
| 246 | + document.querySelectorAll(".chip[data-filter]").forEach(x => { x.classList.remove("active"); x.setAttribute("aria-selected", "false"); }); | |
| 247 | + b.classList.add("active"); b.setAttribute("aria-selected", "true"); | |
| 248 | + filter = b.dataset.filter; renderNodes(); | |
| 249 | + })); | |
| 250 | + $("#sortSel").addEventListener("change", e => { sortKey = e.target.value; renderNodes(); }); | |
| 251 | + | |
| 252 | + // reveal on scroll | |
| 253 | + const io = "IntersectionObserver" in window ? new IntersectionObserver(es => es.forEach(x => { if (x.isIntersecting) { x.target.classList.add("in"); io.unobserve(x.target); } }), { rootMargin: "0px 0px -8% 0px" }) : null; | |
| 254 | + function observeReveal() { if (!io) return; document.querySelectorAll(".section > .sec-head, .stage, .pillar, .tool, .gw").forEach(e => { if (!e.classList.contains("reveal")) { e.classList.add("reveal"); io.observe(e); } }); } | |
| 255 | + | |
| 256 | + renderKPIs(); renderNodes(); renderApps(); renderGateways(); observeReveal(); | |
| 257 | + refresh(); setInterval(refresh, 30000); | |
| 258 | + document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); | |
| 259 | +})(); | |
added
public/data.js
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +/* Inventaire MacLustr — données statiques (mises à jour 2026-09-10). | |
| 2 | + Les états live (en ligne, charge, RAM, santé des apps) viennent de /api/live. */ | |
| 3 | +window.MACLUSTR = { | |
| 4 | + updated: "2026-09-10", | |
| 5 | + totals: { macs: 33, ovh: 4, apps: 35, sites: 34 }, | |
| 6 | + | |
| 7 | + // group: lan | rented | ovh | |
| 8 | + nodes: [ | |
| 9 | + // ---- LAN Saint-Augustin (192.168.2.x, maclustr.io) ---- | |
| 10 | + { id:"M3U96a", group:"lan", model:"Mac Studio", chip:"M3 Ultra", tier:"ultra", cores:32, gpu:80, ram:96, disk:926, ip:"192.168.2.87", | |
| 11 | + role:"Nœud maître de calcul et d'applications", desc:"Le plus gros nœud du cluster. Héberge la console Administration·Ka et une grappe d'applications Next.js/FastAPI (QC26, RareIndex, Fetcha, PolyLLM, UQO).", | |
| 12 | + tags:["worker","apps","calcul"] }, | |
| 13 | + { id:"M3U96b", group:"lan", model:"Mac Studio", chip:"M3 Ultra", tier:"ultra", cores:32, gpu:80, ram:96, disk:926, ip:"192.168.2.82", | |
| 14 | + role:"Réservé à HF Market Data", desc:"Sert le lac de données FirstRate (353 Go de séries intraday, DuckDB) derrière www.hfmarketdata.io. Réservé : aucune autre app n'y est placée.", | |
| 15 | + tags:["worker","data","réservé"] }, | |
| 16 | + { id:"M2U64", group:"lan", model:"Mac Studio", chip:"M2 Ultra", tier:"ultra", cores:24, gpu:76, ram:64, disk:926, ip:"192.168.2.107", | |
| 17 | + role:"Nœud d'applications sans session graphique", desc:"Tourne en LaunchDaemon (headless). Héberge SPB Cloud, vQuant, UQO Éval, Food·Ka et les consoles MacLustr.", | |
| 18 | + tags:["worker","apps","headless"] }, | |
| 19 | + { id:"M4M64a", group:"lan", model:"Mac Studio", chip:"M4 Max", tier:"max", cores:16, gpu:40, ram:64, disk:926, ip:"192.168.2.83", | |
| 20 | + role:"Applications + agent de supervision", desc:"Héberge maclustr-agentd (:9210), la source des apps macOS et iOS MacLustr, ainsi que Lou·Ka, Fabri·Ka, Ka·Stats, UQO-Chat et PDB API.", | |
| 21 | + tags:["worker","apps","monitoring"] }, | |
| 22 | + { id:"M4M64b", group:"lan", model:"Mac Studio", chip:"M4 Max", tier:"max", cores:16, gpu:40, ram:64, disk:926, ip:"192.168.2.78", | |
| 23 | + role:"Applications Groupe Ka + index", desc:"API·Ka (Postgres 17 + Redis), Groupe Ka, Immo·Ka, Auto·Ka, CancerIndex, WebSensor, UQO IMM1003.", | |
| 24 | + tags:["worker","apps","postgres"] }, | |
| 25 | + { id:"M4BP48", group:"lan", model:"MacBook Pro", chip:"M4 Max", tier:"max", cores:16, gpu:40, ram:48, disk:926, ip:"192.168.2.80", | |
| 26 | + role:"Nœud de calcul", desc:"Portable intégré au cluster ; disponible pour les jobs distribués (uv, Python 3.12).", tags:["worker","calcul"] }, | |
| 27 | + { id:"M4BP36", group:"lan", model:"MacBook Pro", chip:"M4 Max", tier:"max", cores:14, gpu:32, ram:36, disk:null, ip:"192.168.2.133", | |
| 28 | + role:"Nœud de calcul (mobile)", desc:"Portable, souvent hors ligne : nœud d'appoint pour le calcul distribué.", tags:["worker","calcul"] }, | |
| 29 | + { id:"M4M36", group:"lan", model:"Mac Studio", chip:"M4 Max", tier:"max", cores:14, gpu:40, ram:36, disk:460, ip:"192.168.2.69", | |
| 30 | + role:"Gardiens Ka", desc:"Héberge les trois agents gardiens ka2, ka4 et ka6 qui surveillent, réparent et poussent les apps Groupe Ka.", tags:["worker","agents"] }, | |
| 31 | + { id:"M2M32", group:"lan", model:"Mac Studio", chip:"M2 Max", tier:"max", cores:12, gpu:38, ram:32, disk:460, ip:"192.168.2.90", | |
| 32 | + role:"Nœud de calcul", desc:"Docker, Node, Python : nœud générique pour le calcul et les déploiements mld.", tags:["worker","calcul"] }, | |
| 33 | + { id:"M2M32b", group:"lan", model:"Mac Studio", chip:"M2 Max", tier:"max", cores:12, gpu:38, ram:32, disk:460, ip:"192.168.2.77", | |
| 34 | + role:"Nœud de calcul", desc:"Nœud générique, ex-flotte de scraping Trouve-Ka.", tags:["worker","calcul"] }, | |
| 35 | + { id:"M2M32c", group:"lan", model:"Mac Studio", chip:"M2 Max", tier:"max", cores:12, gpu:30, ram:32, disk:460, ip:"192.168.2.89", | |
| 36 | + role:"Nœud de calcul", desc:"Nœud générique pour les jobs Python distribués.", tags:["worker","calcul"] }, | |
| 37 | + { id:"m4mc", group:"lan", model:"Mac mini", chip:"M4 Pro", tier:"pro", cores:12, gpu:16, ram:24, disk:460, ip:"192.168.2.75", | |
| 38 | + role:"Nœud de calcul", desc:"Mac mini M4 Pro, nœud générique.", tags:["worker","calcul"] }, | |
| 39 | + { id:"M1M32", group:"lan", model:"Mac Studio", chip:"M1 Max", tier:"max", cores:10, gpu:32, ram:32, disk:460, ip:"192.168.2.76", | |
| 40 | + role:"Passerelle du cluster", desc:"Porte d'entrée : héberge spbgit (git.spboucher.ai, dépôts bare de toutes les apps) et l'orchestrateur mld qui place, déploie, migre et répare chaque application. Aucune app métier n'y tourne.", | |
| 41 | + tags:["gateway","git","orchestrateur"] }, | |
| 42 | + { id:"m4ma", group:"lan", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:24, disk:460, ip:"192.168.2.73", role:"Nœud de calcul", desc:"Mac mini M4, nœud générique.", tags:["worker"] }, | |
| 43 | + { id:"m4mb", group:"lan", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, ip:"192.168.2.74", role:"Nœud de calcul", desc:"Mac mini M4, nœud générique.", tags:["worker"] }, | |
| 44 | + { id:"m4md", group:"lan", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, ip:"192.168.2.110", role:"Nœud de calcul", desc:"Mac mini M4 ajouté le 4 septembre 2026.", tags:["worker"] }, | |
| 45 | + { id:"m2m16", group:"lan", model:"Mac mini", chip:"M2 Pro", tier:"pro", cores:10, gpu:16, ram:16, disk:460, ip:"192.168.2.85", role:"Nœud de calcul", desc:"Mac mini M2 Pro, nœud générique.", tags:["worker"] }, | |
| 46 | + { id:"M3BA24", group:"lan", model:"MacBook Air", chip:"M3", tier:"base", cores:8, gpu:10, ram:24, disk:460, ip:"192.168.2.81", role:"Nœud d'appoint", desc:"MacBook Air, nœud d'appoint pour le calcul léger.", tags:["worker"] }, | |
| 47 | + { id:"M3BA16", group:"lan", model:"MacBook Air", chip:"M3", tier:"base", cores:8, gpu:10, ram:16, disk:460, ip:"192.168.2.70", role:"Nœud d'appoint", desc:"MacBook Air, nœud d'appoint.", tags:["worker"] }, | |
| 48 | + { id:"m2m8a", group:"lan", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:8, disk:460, ip:"192.168.2.84", role:"Nœud léger", desc:"Mac mini M2 8 Go : tâches légères et tests.", tags:["worker"] }, | |
| 49 | + { id:"m2m8b", group:"lan", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:8, disk:228, ip:"192.168.2.72", role:"Nœud léger", desc:"Mac mini M2 8 Go : tâches légères et tests.", tags:["worker"] }, | |
| 50 | + | |
| 51 | + // ---- Macs dédiés loués, hors LAN ---- | |
| 52 | + { id:"M1M64", group:"rented", model:"Mac Studio", chip:"M1 Max", tier:"max", cores:10, gpu:32, ram:64, disk:1800, provider:"Macly", location:"Centre de données Macly", | |
| 53 | + role:"Nœud distant à grande mémoire", desc:"Mac Studio loué, 64 Go et 1,8 To : réservé aux déploiements explicites (mld deploy --node M1M64).", tags:["distant","réservé"] }, | |
| 54 | + { id:"m2m16b", group:"rented", model:"Mac mini", chip:"M2 Pro", tier:"pro", cores:10, gpu:16, ram:16, disk:926, provider:"MacStadium", location:"Centre de données MacStadium", | |
| 55 | + role:"Nœud distant", desc:"Mac mini M2 Pro dédié, raccordé au tunnel MacLustr.", tags:["distant","réservé"] }, | |
| 56 | + { id:"m1m16", group:"rented", model:"Mac mini", chip:"M1", tier:"base", cores:8, gpu:8, ram:16, disk:926, provider:"MacStadium", location:"Centre de données MacStadium", | |
| 57 | + role:"Nœud distant", desc:"Mac mini M1 dédié.", tags:["distant","réservé"] }, | |
| 58 | + { id:"m2m16c", group:"rented", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:16, disk:926, provider:"MacStadium", location:"Centre de données MacStadium", | |
| 59 | + role:"Nœud distant", desc:"Mac mini M2 dédié.", tags:["distant","réservé"] }, | |
| 60 | + { id:"m4me", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 61 | + role:"Nœud distant", desc:"Mac mini M4 dédié, amorcé à distance (DeskIn + script), PM2 en LaunchDaemon.", tags:["distant","réservé"] }, | |
| 62 | + { id:"m4mf", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 63 | + role:"Nœud distant", desc:"Jumeau de m4me.", tags:["distant","réservé"] }, | |
| 64 | + { id:"m4mg", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"rentamac", location:"Limassol, Chypre", | |
| 65 | + role:"Nœud distant (Europe)", desc:"Derrière NAT, joint par la passerelle SSH du fournisseur. Xcode complet préinstallé.", tags:["distant","réservé","europe"] }, | |
| 66 | + { id:"m4mh", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 67 | + role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, | |
| 68 | + { id:"m1m16b", group:"rented", model:"Mac mini", chip:"M1", tier:"base", cores:8, gpu:8, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 69 | + role:"Nœud distant", desc:"Mac mini M1 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, | |
| 70 | + { id:"m4mi", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 71 | + role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, | |
| 72 | + { id:"m4mj", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 73 | + role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, | |
| 74 | + { id:"m4mk", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly", | |
| 75 | + role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, | |
| 76 | + | |
| 77 | + // ---- Serveurs dédiés OVHcloud (Linux) ---- | |
| 78 | + { id:"BHS64", group:"ovh", model:"OVH ADVANCE-2", chip:"AMD EPYC 4345P", tier:"epyc", cores:16, threads:true, gpu:0, ram:64, disk:960, provider:"OVHcloud", location:"Beauharnois, Québec", | |
| 79 | + role:"Passerelle principale MacLustr Tunnel", desc:"Point d'entrée public de tous les sites : hub WireGuard 10.67.0.1 + Caddy (TLS Let's Encrypt automatique). EPYC 4345P 8 c/16 t, 64 Go DDR5 ECC, 2 × 960 Go NVMe RAID 1, 3 Gbit/s, Ubuntu 24.04. ≈ 10 ms depuis le cluster.", | |
| 80 | + tags:["gateway","wireguard","caddy","linux"] }, | |
| 81 | + { id:"BHS64b", group:"ovh", model:"OVH ADVANCE-2", chip:"AMD EPYC 4345P", tier:"epyc", cores:16, threads:true, gpu:0, ram:64, disk:960, provider:"OVHcloud", location:"Beauharnois, Québec", | |
| 82 | + role:"Serveur de réserve", desc:"Jumeau de BHS64 : EPYC 4345P 8 c/16 t, 64 Go DDR5 ECC, 2 × 960 Go NVMe RAID 1, 3 Gbit/s, Ubuntu 24.04. Réservé pour un usage à venir, hors tunnel.", tags:["linux","réserve"] }, | |
| 83 | + { id:"BHS128", group:"ovh", model:"OVH ADVANCE-1", chip:"AMD EPYC 4245P", tier:"epyc", cores:12, threads:true, gpu:0, ram:128, disk:960, provider:"OVHcloud", location:"Beauharnois, Québec", | |
| 84 | + role:"Serveur de réserve à grande mémoire", desc:"EPYC 4245P 6 c/12 t, 128 Go DDR5, 2 × 960 Go NVMe RAID 1, 3 Gbit/s, Ubuntu 24.04. Prêt à accueillir une application ou une base de données exigeante ; hors tunnel.", tags:["linux","réserve","128 Go"] }, | |
| 85 | + { id:"R9128", group:"ovh", model:"OVH RISE-L", chip:"AMD Ryzen 9 9950X", tier:"ryzen", cores:32, threads:true, gpu:0, ram:128, disk:960, provider:"OVHcloud", location:"Gravelines, France", | |
| 86 | + role:"Passerelle secondaire (Europe)", desc:"Hub WireGuard 10.66.0.1 + Caddy : secours et présence européenne du tunnel. Ryzen 9 9950X 16 c/32 t, 128 Go DDR5, 2 × 960 Go NVMe RAID 1, 1 Gbit/s, Ubuntu 24.04.", | |
| 87 | + tags:["gateway","wireguard","caddy","linux","europe"] } | |
| 88 | + ], | |
| 89 | + | |
| 90 | + appGroups: [ | |
| 91 | + { id:"ka", name:"Groupe Ka", blurb:"L'écosystème immobilier, automobile, alimentaire et emploi du Québec : une famille de plateformes qui partagent identité (KA ID), données (API·Ka) et gardiens autonomes." }, | |
| 92 | + { id:"data", name:"Données & intelligence", blurb:"Indices, marchés et infrastructures de données servis en API publiques." }, | |
| 93 | + { id:"uqo", name:"Enseignement UQO", blurb:"Outils de cours interactifs, tuteur IA et ateliers d'évaluation immobilière." }, | |
| 94 | + { id:"platform", name:"Plateforme MacLustr", blurb:"Les services qui font tourner le cluster lui-même." }, | |
| 95 | + { id:"spb", name:"SPB", blurb:"Sites et services personnels." } | |
| 96 | + ], | |
| 97 | + | |
| 98 | + apps: [ | |
| 99 | + { id:"groupe-ka", group:"ka", name:"Groupe Ka", domain:"www.groupe-ka.com", node:"M4M64b", port:8110, desc:"Portail du groupe : vision, univers, dossier investisseurs." }, | |
| 100 | + { id:"lou-ka", group:"ka", name:"Lou·Ka", domain:"www.lou-ka.com", node:"M4M64a", port:8095, desc:"Location résidentielle : annonces, carte 3D, Lou-Ka Score." }, | |
| 101 | + { id:"immo-ka", group:"ka", name:"Immo·Ka", domain:"www.immo-ka.com", node:"M4M64b", port:8096, desc:"Propriétés à vendre : 70 000+ annonces, analyses de prix, KA Scores." }, | |
| 102 | + { id:"vrai-prix", group:"ka", name:"Vrai-Prix", domain:"www.vrai-prix.com", node:"M3U96a", port:8090, desc:"Évaluation hédonique, méthode du coût, statistiques de marché." }, | |
| 103 | + { id:"auto-ka", group:"ka", name:"Auto·Ka", domain:"www.auto-ka.com", node:"M4M64b", port:8095, desc:"Marché automobile : véhicules, prix, fiches v3." }, | |
| 104 | + { id:"fabri-ka", group:"ka", name:"Fabri·Ka", domain:"www.fabri-ka.com", node:"M4M64a", port:8097, desc:"Artisans, fabricants et ateliers du Québec." }, | |
| 105 | + { id:"food-ka", group:"ka", name:"Food·Ka", domain:"www.food-ka.com", node:"M2U64", port:8097, desc:"Alimentation : produits, commerces, prix." }, | |
| 106 | + { id:"sorti-ka", group:"ka", name:"Sorti·Ka", domain:"www.sorti-ka.com", node:"M3U96a", port:8120, desc:"Sorties, événements et activités." }, | |
| 107 | + { id:"job-ka", group:"ka", name:"Job·Ka", domain:"www.job-ka.com", node:"M3U96a", port:8096, desc:"Emplois et carrières." }, | |
| 108 | + { id:"api-ka", group:"ka", name:"API·Ka", domain:"www.api-ka.com", node:"M4M64b", port:8000, desc:"API unifiée du groupe : Postgres 17, Redis, 24 outils agent." }, | |
| 109 | + { id:"ka-stats", group:"ka", name:"Ka·Stats", domain:"www.ka-stats.com", node:"M4M64a", port:8140, desc:"Observatoire statistique des univers Ka." }, | |
| 110 | + { id:"admin-ka", group:"ka", name:"Administration·Ka", domain:"www.administration-ka.com", node:"M3U96a", port:3300, desc:"Console d'administration multi-sites, pilotée par le registre mld." }, | |
| 111 | + { id:"ka2", group:"ka", name:"Gardien ka2", domain:"www.ka2.bot", node:"M4M36", port:8799, desc:"Agent gardien autonome : surveillance et réparation." }, | |
| 112 | + { id:"ka4", group:"ka", name:"Gardien ka4", domain:"www.ka4.bot", node:"M4M36", port:8899, desc:"Agent gardien autonome." }, | |
| 113 | + { id:"ka6", group:"ka", name:"Gardien ka6", domain:"www.ka6.bot", node:"M4M36", port:8999, desc:"Agent gardien autonome." }, | |
| 114 | + | |
| 115 | + { id:"hfmarketdata", group:"data", name:"HF Market Data", domain:"www.hfmarketdata.io", node:"M3U96b", port:8090, desc:"Données de marché haute fréquence : actions, futures, options, fondamentaux SEC. API + MCP." }, | |
| 116 | + { id:"rareindex", group:"data", name:"RareIndex", domain:"www.rareindex.io", node:"M3U96a", port:8210, desc:"Intelligence de marché des objets de collection : 212 connecteurs." }, | |
| 117 | + { id:"cancerindex", group:"data", name:"CancerIndex", domain:"www.cancerindex.io", node:"M4M64b", port:8250, desc:"Index mondial des cancers : 9 510 entités NCIt, 13 connecteurs, pgvector." }, | |
| 118 | + { id:"websensor", group:"data", name:"WebSensor", domain:"www.websensor.io", node:"M4M64b", port:8260, desc:"Détection de changements web : 4 000+ capteurs, 16 connecteurs." }, | |
| 119 | + { id:"fetcha", group:"data", name:"Fetcha", domain:"www.fetcha.co", node:"M3U96a", port:8220, desc:"Infrastructure d'accès web intelligent : scrape, crawl, map, SDK." }, | |
| 120 | + { id:"polyllm", group:"data", name:"PolyLLM", domain:"www.polyllm.io", node:"M3U96a", port:8240, desc:"Une interface, tous les modèles : 9 fournisseurs, Arena, BYOK." }, | |
| 121 | + { id:"vquant", group:"data", name:"vQuant", domain:"www.vquant.ai", node:"M2U64", port:5000, desc:"Terminal quantitatif : indices, portefeuilles, MFA." }, | |
| 122 | + { id:"qc26", group:"data", name:"QC26", domain:"www.qc26.ca", node:"M3U96a", port:8200, desc:"L'élection québécoise 2026 en données : sondages, Monte Carlo, revue de presse." }, | |
| 123 | + { id:"pdb-api", group:"data", name:"PDB API", domain:"www.pdb-api.co", node:"M4M64a", port:8270, desc:"Explorateur et API de la base SPID (FastAPI + DuckDB)." }, | |
| 124 | + | |
| 125 | + { id:"uqo-eval", group:"uqo", name:"UQO Éval", domain:"www.uqo-eval.app", node:"M2U64", port:8150, desc:"Atelier d'évaluation immobilière : hédonique, coût, à vendre, marché." }, | |
| 126 | + { id:"uqo-imm1003", group:"uqo", name:"UQO IMM1003", domain:"www.uqo-imm1003.app", node:"M4M64b", port:8160, desc:"Notes de cours interactives et séances vidéo." }, | |
| 127 | + { id:"uqo-imm1033", group:"uqo", name:"UQO IMM1033", domain:"www.uqo-imm1033.app", node:"M3U96a", port:8161, desc:"Notes de cours interactives et séances vidéo." }, | |
| 128 | + { id:"uqo-chat", group:"uqo", name:"UQO-Chat", domain:"www.uqo-chat.app", node:"M4M64a", port:8190, desc:"Tuteur IA des cours IMM1003 et IMM1033, sur invitation." }, | |
| 129 | + | |
| 130 | + { id:"maclustr-agentd", group:"platform", name:"maclustr-agentd", domain:null, node:"M4M64a", port:9210, desc:"Agent de supervision : métriques des 33 nœuds, santé des apps, état du tunnel. Alimente les apps macOS et iOS MacLustr et ce site." }, | |
| 131 | + { id:"maclustr-console", group:"platform", name:"Console MacLustr", domain:"console.maclustr.io", node:"M2U64", port:3000, desc:"Console web du cluster." }, | |
| 132 | + { id:"maclustr-appmanager", group:"platform", name:"App Manager", domain:"app.maclustr.io", node:"M2U64", port:8787, desc:"Gestionnaire d'applications." }, | |
| 133 | + { id:"maclustr-nodepages", group:"platform", name:"Node Pages", domain:"node.maclustr.io", node:"M2U64", port:8003, desc:"Pages de bord par nœud." }, | |
| 134 | + { id:"spbgit", group:"platform", name:"spbgit", domain:"git.spboucher.ai", node:"M1M32", port:7420, desc:"Forge git privée : dépôts bare de toutes les apps, source de vérité du code." }, | |
| 135 | + | |
| 136 | + { id:"spboucher-ai", group:"spb", name:"spboucher.ai", domain:"www.spboucher.ai", node:"M2U64", port:3100, desc:"Site personnel." }, | |
| 137 | + { id:"spb-cloud", group:"spb", name:"SPB Cloud", domain:"cloud.spboucher.ai", node:"M2U64", port:5001, desc:"Nuage privé : fichiers, partage, OTP, réorganisation IA." } | |
| 138 | + ], | |
| 139 | + | |
| 140 | + tunnel: { | |
| 141 | + gateways: [ | |
| 142 | + { id:"BHS64", ip:"51.161.112.61", site:"Beauharnois, Québec", primary:true, wg:"10.67.0.0/24" }, | |
| 143 | + { id:"R9128", ip:"51.255.75.61", site:"Gravelines, France", primary:false, wg:"10.66.0.0/24" } | |
| 144 | + ] | |
| 145 | + } | |
| 146 | +}; | |
added
public/favicon.svg
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#2dd4bf"/><stop offset="1" stop-color="#818cf8"/></linearGradient></defs><rect width="32" height="32" rx="7" fill="#070a12"/><rect x="5" y="5" width="10" height="10" rx="2.5" fill="url(#g)"/><rect x="17" y="5" width="10" height="10" rx="2.5" fill="url(#g)" opacity=".75"/><rect x="5" y="17" width="10" height="10" rx="2.5" fill="url(#g)" opacity=".75"/><rect x="17" y="17" width="10" height="10" rx="2.5" fill="url(#g)" opacity=".5"/></svg> | |
added
public/index.html
+188 −0
@@ -0,0 +1,188 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="fr"> | |
| 3 | +<head> | |
| 4 | +<meta charset="utf-8"> | |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> | |
| 6 | +<title>MacLustr — Le cluster Apple Silicon de Saint-Augustin</title> | |
| 7 | +<meta name="description" content="MacLustr : 33 Macs Apple Silicon, 4 serveurs OVHcloud, 484 cœurs, 1,4 To de RAM. Un cluster privé qui héberge 35 applications en production, exposées au monde par le MacLustr Tunnel."> | |
| 8 | +<meta name="theme-color" content="#070a12"> | |
| 9 | +<meta property="og:title" content="MacLustr — Le cluster Apple Silicon"> | |
| 10 | +<meta property="og:description" content="33 Macs, 4 serveurs OVH, 484 cœurs, 1,4 To de RAM, 35 applications en production."> | |
| 11 | +<meta property="og:type" content="website"> | |
| 12 | +<meta property="og:url" content="https://www.maclustr.io/"> | |
| 13 | +<link rel="icon" href="/favicon.svg" type="image/svg+xml"> | |
| 14 | +<link rel="stylesheet" href="/styles.css"> | |
| 15 | +</head> | |
| 16 | +<body> | |
| 17 | +<div class="bg" aria-hidden="true"><div class="orb o1"></div><div class="orb o2"></div><div class="orb o3"></div><div class="grid-lines"></div></div> | |
| 18 | + | |
| 19 | +<header class="top"> | |
| 20 | + <a class="brand" href="#top" aria-label="MacLustr"> | |
| 21 | + <svg class="logo" viewBox="0 0 32 32" aria-hidden="true"><defs><linearGradient id="lg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#2dd4bf"/><stop offset="1" stop-color="#818cf8"/></linearGradient></defs><rect x="2" y="2" width="12" height="12" rx="3" fill="url(#lg)"/><rect x="18" y="2" width="12" height="12" rx="3" fill="url(#lg)" opacity=".75"/><rect x="2" y="18" width="12" height="12" rx="3" fill="url(#lg)" opacity=".75"/><rect x="18" y="18" width="12" height="12" rx="3" fill="url(#lg)" opacity=".5"/></svg> | |
| 22 | + <span>MacLustr</span> | |
| 23 | + </a> | |
| 24 | + <nav class="nav" aria-label="Sections"> | |
| 25 | + <a href="#architecture">Architecture</a> | |
| 26 | + <a href="#noeuds">Nœuds</a> | |
| 27 | + <a href="#apps">Applications</a> | |
| 28 | + <a href="#tunnel">Tunnel</a> | |
| 29 | + <a href="#pile">Pile</a> | |
| 30 | + </nav> | |
| 31 | + <div class="live-pill" id="livePill" role="status" aria-live="polite"><span class="dot"></span><span class="txt">connexion…</span></div> | |
| 32 | +</header> | |
| 33 | + | |
| 34 | +<main id="top"> | |
| 35 | + <!-- ============ HERO ============ --> | |
| 36 | + <section class="hero"> | |
| 37 | + <p class="eyebrow">Cluster privé · Apple Silicon · Saint-Augustin-de-Desmaures, Québec</p> | |
| 38 | + <h1>Un supercalculateur<br><span class="grad">fait de Macs.</span></h1> | |
| 39 | + <p class="lede">MacLustr réunit <strong>33 Macs Apple Silicon</strong> et <strong>4 serveurs OVHcloud</strong> en une seule plateforme : orchestration automatique, tunnel public chiffré, supervision en continu. Elle héberge <strong>35 applications</strong> en production, de l'immobilier québécois aux données de marché haute fréquence.</p> | |
| 40 | + <div class="cta-row"> | |
| 41 | + <a class="btn primary" href="#noeuds">Explorer les nœuds</a> | |
| 42 | + <a class="btn ghost" href="#apps">Voir les applications</a> | |
| 43 | + </div> | |
| 44 | + | |
| 45 | + <div class="kpis" id="kpis"> | |
| 46 | + <div class="kpi"><div class="kpi-v" data-kpi="nodes">—</div><div class="kpi-l">nœuds</div><div class="kpi-s" data-kpi="nodesSub">33 Macs + 4 Linux</div></div> | |
| 47 | + <div class="kpi"><div class="kpi-v" data-kpi="cores">—</div><div class="kpi-l">cœurs CPU</div><div class="kpi-s" data-kpi="coresSub">Apple Silicon + EPYC + Ryzen</div></div> | |
| 48 | + <div class="kpi"><div class="kpi-v" data-kpi="gpu">—</div><div class="kpi-l">cœurs GPU</div><div class="kpi-s">Metal, unifiés</div></div> | |
| 49 | + <div class="kpi"><div class="kpi-v" data-kpi="ram">—</div><div class="kpi-l">RAM totale</div><div class="kpi-s" data-kpi="ramSub">mémoire unifiée</div></div> | |
| 50 | + <div class="kpi"><div class="kpi-v" data-kpi="apps">—</div><div class="kpi-l">applications</div><div class="kpi-s" data-kpi="appsSub">en production</div></div> | |
| 51 | + <div class="kpi"><div class="kpi-v" data-kpi="sites">—</div><div class="kpi-l">sites publics</div><div class="kpi-s">HTTPS via MacLustr Tunnel</div></div> | |
| 52 | + </div> | |
| 53 | + </section> | |
| 54 | + | |
| 55 | + <!-- ============ ARCHITECTURE ============ --> | |
| 56 | + <section class="section" id="architecture"> | |
| 57 | + <div class="sec-head"> | |
| 58 | + <p class="eyebrow">Architecture</p> | |
| 59 | + <h2>Du portable au monde entier, <span class="grad">en cinq étages.</span></h2> | |
| 60 | + <p class="sub">Une seule commande, <code>mld deploy</code>, choisit le meilleur nœud, synchronise le code, démarre les processus, pose la route publique et vérifie la santé. Rien n'est placé à la main.</p> | |
| 61 | + </div> | |
| 62 | + | |
| 63 | + <div class="flow"> | |
| 64 | + <div class="stage"> | |
| 65 | + <div class="stage-ico">💻</div> | |
| 66 | + <h3>Poste de travail</h3> | |
| 67 | + <p>Le code est mis en scène vers la passerelle avec <code>mld stage</code>. Claude Code pilote la plupart des opérations.</p> | |
| 68 | + </div> | |
| 69 | + <div class="arrow" aria-hidden="true"></div> | |
| 70 | + <div class="stage accent"> | |
| 71 | + <div class="stage-ico">🗝️</div> | |
| 72 | + <h3>Passerelle M1M32</h3> | |
| 73 | + <p><strong>spbgit</strong> (forge git privée) et l'orchestrateur <strong>mld</strong> : scan live des nœuds, score, rsync LAN, PM2, registre, auto-réparation toutes les 5 min.</p> | |
| 74 | + </div> | |
| 75 | + <div class="arrow" aria-hidden="true"></div> | |
| 76 | + <div class="stage"> | |
| 77 | + <div class="stage-ico">🖥️</div> | |
| 78 | + <h3>33 nœuds Mac</h3> | |
| 79 | + <p>21 Macs sur le LAN, 12 Macs dédiés loués dans quatre centres de données. Chaque app tourne sous PM2 ou launchd, sur le nœud le plus efficient.</p> | |
| 80 | + </div> | |
| 81 | + <div class="arrow" aria-hidden="true"></div> | |
| 82 | + <div class="stage"> | |
| 83 | + <div class="stage-ico">🔐</div> | |
| 84 | + <h3>WireGuard</h3> | |
| 85 | + <p>Chaque nœud tient deux tunnels chiffrés permanents vers les passerelles OVH (wg1 → Québec, wg0 → France). Aucun port ouvert chez nous.</p> | |
| 86 | + </div> | |
| 87 | + <div class="arrow" aria-hidden="true"></div> | |
| 88 | + <div class="stage accent"> | |
| 89 | + <div class="stage-ico">🌐</div> | |
| 90 | + <h3>Serveurs OVHcloud</h3> | |
| 91 | + <p><strong>BHS64</strong> à Beauharnois (passerelle principale, ≈ 10 ms) et <strong>R9128</strong> à Gravelines terminent le TLS et routent chaque domaine vers son nœud ; <strong>BHS64b</strong> et <strong>BHS128</strong> (128 Go) sont en réserve.</p> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + <div class="pillars"> | |
| 96 | + <div class="pillar"><span class="pi">⚡</span><h4>Placement par score</h4><p>RAM libre, cœurs inactifs, disque, apps déjà hébergées : le nœud gagnant est calculé à chaque déploiement, et une app peut migrer à chaud avec <code>mld move</code>.</p></div> | |
| 97 | + <div class="pillar"><span class="pi">🛡️</span><h4>Résilience autonome</h4><p>Auto-login, veille désactivée, redémarrage après coupure, <code>mld heal</code> qui relance PM2, launchd, WireGuard et les routes. Le 10 septembre 2026, 16 nœuds ont redémarré d'un coup et tout est revenu seul.</p></div> | |
| 98 | + <div class="pillar"><span class="pi">📡</span><h4>Supervision native</h4><p>L'agent <strong>maclustr-agentd</strong> sonde les 33 nœuds et 35 apps ; les apps macOS et iOS MacLustr, et cette page, affichent l'état en direct.</p></div> | |
| 99 | + <div class="pillar"><span class="pi">🧭</span><h4>Registre unique</h4><p>« Quelle app tourne où ? » n'a qu'une réponse : le registre mld, poussé aux consommateurs (gardiens Ka, console admin). Aucun emplacement codé en dur.</p></div> | |
| 100 | + </div> | |
| 101 | + </section> | |
| 102 | + | |
| 103 | + <!-- ============ NŒUDS ============ --> | |
| 104 | + <section class="section" id="noeuds"> | |
| 105 | + <div class="sec-head"> | |
| 106 | + <p class="eyebrow">Inventaire</p> | |
| 107 | + <h2>Les <span class="grad" id="nodeCountTitle">37 nœuds</span>, un par un.</h2> | |
| 108 | + <p class="sub">Trois familles : le LAN de Saint-Augustin, les Macs dédiés loués hors LAN, et quatre serveurs Linux OVHcloud (deux passerelles, deux réserves). L'état, la charge et la mémoire sont rafraîchis toutes les 30 secondes.</p> | |
| 109 | + </div> | |
| 110 | + | |
| 111 | + <div class="toolbar"> | |
| 112 | + <div class="chips" role="tablist" aria-label="Filtrer les nœuds"> | |
| 113 | + <button class="chip active" data-filter="all" role="tab" aria-selected="true">Tous <span class="n" id="cnt-all"></span></button> | |
| 114 | + <button class="chip" data-filter="lan" role="tab" aria-selected="false">LAN <span class="n" id="cnt-lan"></span></button> | |
| 115 | + <button class="chip" data-filter="rented" role="tab" aria-selected="false">Macs loués <span class="n" id="cnt-rented"></span></button> | |
| 116 | + <button class="chip" data-filter="ovh" role="tab" aria-selected="false">OVHcloud <span class="n" id="cnt-ovh"></span></button> | |
| 117 | + </div> | |
| 118 | + <label class="sort">Trier | |
| 119 | + <select id="sortSel" aria-label="Trier les nœuds"> | |
| 120 | + <option value="cores">par cœurs</option> | |
| 121 | + <option value="ram">par RAM</option> | |
| 122 | + <option value="name">par nom</option> | |
| 123 | + <option value="load">par charge live</option> | |
| 124 | + </select> | |
| 125 | + </label> | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <div class="group-summary" id="groupSummary"></div> | |
| 129 | + <div class="nodes" id="nodes"></div> | |
| 130 | + </section> | |
| 131 | + | |
| 132 | + <!-- ============ APPS ============ --> | |
| 133 | + <section class="section" id="apps"> | |
| 134 | + <div class="sec-head"> | |
| 135 | + <p class="eyebrow">Hébergement</p> | |
| 136 | + <h2><span class="grad" id="appCountTitle">35 applications</span> en production.</h2> | |
| 137 | + <p class="sub">Chaque application a un manifeste, un nœud courant, un processus supervisé et, pour les sites publics, une route HTTPS sur la passerelle. Le point vert indique une santé publique confirmée à l'instant.</p> | |
| 138 | + </div> | |
| 139 | + <div id="appGroups"></div> | |
| 140 | + </section> | |
| 141 | + | |
| 142 | + <!-- ============ TUNNEL ============ --> | |
| 143 | + <section class="section" id="tunnel"> | |
| 144 | + <div class="sec-head"> | |
| 145 | + <p class="eyebrow">MacLustr Tunnel</p> | |
| 146 | + <h2>Deux passerelles, <span class="grad">zéro port ouvert.</span></h2> | |
| 147 | + <p class="sub">Les sites sont publiés par des serveurs OVHcloud dédiés : le trafic HTTPS arrive à Beauharnois ou Gravelines, puis rejoint le nœud par WireGuard. Les DNS décident de la passerelle ; une route peut vivre sur les deux.</p> | |
| 148 | + </div> | |
| 149 | + <div class="gateways" id="gateways"></div> | |
| 150 | + </section> | |
| 151 | + | |
| 152 | + <!-- ============ PILE ============ --> | |
| 153 | + <section class="section" id="pile"> | |
| 154 | + <div class="sec-head"> | |
| 155 | + <p class="eyebrow">Pile technique</p> | |
| 156 | + <h2>Des outils <span class="grad">faits maison</span>, sur des briques éprouvées.</h2> | |
| 157 | + </div> | |
| 158 | + <div class="stack"> | |
| 159 | + <div class="tool"><h4>mld</h4><p>Orchestrateur de déploiement (Python) : scan, score, stage, deploy, move, heal, harden, tunnel.</p></div> | |
| 160 | + <div class="tool"><h4>spbgit</h4><p>Forge git privée sur la passerelle, origin de toutes les apps.</p></div> | |
| 161 | + <div class="tool"><h4>maclustr-agentd</h4><p>Agent de supervision : métriques, santé, historique SQLite, flux SSE, état du tunnel.</p></div> | |
| 162 | + <div class="tool"><h4>MacLustr macOS & iOS</h4><p>Applications natives Swift pour surveiller le cluster depuis le bureau ou le téléphone.</p></div> | |
| 163 | + <div class="tool"><h4>Gardiens ka2 · ka4 · ka6</h4><p>Agents autonomes qui surveillent et réparent les apps Groupe Ka.</p></div> | |
| 164 | + <div class="tool"><h4>PM2 + launchd</h4><p>Supervision des processus sur chaque Mac, résurrection au démarrage.</p></div> | |
| 165 | + <div class="tool"><h4>WireGuard + Caddy</h4><p>Tunnel chiffré et TLS automatique sur les passerelles OVH ; outil <code>tunnelctl</code>.</p></div> | |
| 166 | + <div class="tool"><h4>Node · Python · Postgres · DuckDB</h4><p>Next.js, FastAPI, Postgres 17, Redis, DuckDB, MLX : la pile des applications.</p></div> | |
| 167 | + <div class="tool"><h4>Claude Code</h4><p>L'assistant qui construit, déploie et répare la plateforme au quotidien.</p></div> | |
| 168 | + </div> | |
| 169 | + </section> | |
| 170 | +</main> | |
| 171 | + | |
| 172 | +<footer class="foot"> | |
| 173 | + <div> | |
| 174 | + <strong>MacLustr</strong> · cluster privé de Simon-Pierre Boucher · Saint-Augustin-de-Desmaures, Québec | |
| 175 | + </div> | |
| 176 | + <div class="foot-links"> | |
| 177 | + <a href="https://www.spboucher.ai" rel="noopener">spboucher.ai</a> | |
| 178 | + <a href="https://git.spboucher.ai" rel="noopener">spbgit</a> | |
| 179 | + <a href="https://www.groupe-ka.com" rel="noopener">Groupe Ka</a> | |
| 180 | + <a href="https://www.hfmarketdata.io" rel="noopener">HF Market Data</a> | |
| 181 | + </div> | |
| 182 | + <div class="foot-meta" id="footMeta">Inventaire au 10 septembre 2026.</div> | |
| 183 | +</footer> | |
| 184 | + | |
| 185 | +<script src="/data.js"></script> | |
| 186 | +<script src="/app.js"></script> | |
| 187 | +</body> | |
| 188 | +</html> | |
added
public/styles.css
+226 −0
@@ -0,0 +1,226 @@ | ||
| 1 | +/* MacLustr — www.maclustr.io — mobile-first */ | |
| 2 | +:root { | |
| 3 | + --bg: #070a12; | |
| 4 | + --bg-2: #0b1020; | |
| 5 | + --surface: rgba(255,255,255,.045); | |
| 6 | + --surface-2: rgba(255,255,255,.075); | |
| 7 | + --line: rgba(255,255,255,.10); | |
| 8 | + --line-2: rgba(255,255,255,.18); | |
| 9 | + --ink: #eef2ff; | |
| 10 | + --ink-2: #aab3cc; | |
| 11 | + --ink-3: #6f7a99; | |
| 12 | + --teal: #2dd4bf; | |
| 13 | + --indigo: #818cf8; | |
| 14 | + --violet: #c084fc; | |
| 15 | + --grad: linear-gradient(100deg, var(--teal), var(--indigo) 55%, var(--violet)); | |
| 16 | + --good: #34d399; | |
| 17 | + --warn: #fbbf24; | |
| 18 | + --bad: #f87171; | |
| 19 | + --off: #64748b; | |
| 20 | + --r: 18px; | |
| 21 | + --r-s: 10px; | |
| 22 | + --pad: clamp(16px, 4vw, 40px); | |
| 23 | + --maxw: 1240px; | |
| 24 | + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", Roboto, sans-serif; | |
| 25 | + color-scheme: dark; | |
| 26 | +} | |
| 27 | +* { box-sizing: border-box; } | |
| 28 | +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } | |
| 29 | +body { margin: 0; background: var(--bg); color: var(--ink); line-height: 1.55; overflow-x: hidden; -webkit-font-smoothing: antialiased; } | |
| 30 | +a { color: inherit; text-decoration: none; } | |
| 31 | +code { font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: .92em; background: rgba(255,255,255,.07); padding: .08em .4em; border-radius: 6px; color: #d9e4ff; } | |
| 32 | +h1, h2, h3, h4 { margin: 0; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; } | |
| 33 | +p { margin: 0; } | |
| 34 | +.grad { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } | |
| 35 | +.eyebrow { font-size: .78rem; text-transform: uppercase; letter-spacing: .14em; color: var(--teal); font-weight: 600; margin-bottom: 12px; } | |
| 36 | + | |
| 37 | +/* background */ | |
| 38 | +.bg { position: fixed; inset: 0; z-index: -1; overflow: hidden; background: radial-gradient(1200px 600px at 70% -10%, #131a35 0%, transparent 60%), var(--bg); } | |
| 39 | +.orb { position: absolute; border-radius: 50%; filter: blur(80px); opacity: .28; animation: drift 24s ease-in-out infinite alternate; } | |
| 40 | +.o1 { width: 520px; height: 520px; left: -160px; top: -120px; background: var(--teal); } | |
| 41 | +.o2 { width: 620px; height: 620px; right: -200px; top: 20vh; background: var(--indigo); animation-delay: -8s; } | |
| 42 | +.o3 { width: 460px; height: 460px; left: 30vw; top: 120vh; background: var(--violet); animation-delay: -16s; } | |
| 43 | +@keyframes drift { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(60px, 40px, 0) scale(1.08); } } | |
| 44 | +.grid-lines { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 56px 56px; mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 70%); -webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 70%); } | |
| 45 | +@media (prefers-reduced-motion: reduce) { .orb { animation: none; } html { scroll-behavior: auto; } } | |
| 46 | + | |
| 47 | +/* header */ | |
| 48 | +.top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 14px; padding: 10px var(--pad); padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(7,10,18,.65); backdrop-filter: saturate(160%) blur(18px); -webkit-backdrop-filter: saturate(160%) blur(18px); border-bottom: 1px solid var(--line); } | |
| 49 | +.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 1.12rem; } | |
| 50 | +.logo { width: 26px; height: 26px; } | |
| 51 | +.nav { display: none; gap: 4px; margin-left: 10px; } | |
| 52 | +.nav a { padding: 7px 12px; border-radius: 999px; color: var(--ink-2); font-size: .92rem; font-weight: 500; } | |
| 53 | +.nav a:hover { background: var(--surface-2); color: var(--ink); } | |
| 54 | +.live-pill { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--ink-2); white-space: nowrap; } | |
| 55 | +.live-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--off); box-shadow: 0 0 0 0 rgba(52,211,153,.5); } | |
| 56 | +.live-pill.ok .dot { background: var(--good); animation: pulse 2s infinite; } | |
| 57 | +.live-pill.bad .dot { background: var(--bad); } | |
| 58 | +@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(52,211,153,.55); } 70% { box-shadow: 0 0 0 8px rgba(52,211,153,0); } 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); } } | |
| 59 | +@media (min-width: 900px) { .nav { display: flex; } } | |
| 60 | + | |
| 61 | +/* layout */ | |
| 62 | +main { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); } | |
| 63 | +.section { padding: clamp(56px, 9vw, 110px) 0 0; } | |
| 64 | +.sec-head { max-width: 760px; margin-bottom: clamp(24px, 4vw, 40px); } | |
| 65 | +.sec-head h2 { font-size: clamp(1.7rem, 4.6vw, 2.7rem); } | |
| 66 | +.sec-head .sub { margin-top: 14px; color: var(--ink-2); font-size: clamp(.98rem, 1.6vw, 1.1rem); } | |
| 67 | + | |
| 68 | +/* hero */ | |
| 69 | +.hero { padding: clamp(48px, 10vw, 120px) 0 0; } | |
| 70 | +.hero h1 { font-size: clamp(2.5rem, 8.5vw, 5.4rem); letter-spacing: -.035em; font-weight: 800; } | |
| 71 | +.lede { margin-top: 22px; max-width: 720px; color: var(--ink-2); font-size: clamp(1.02rem, 1.9vw, 1.22rem); } | |
| 72 | +.lede strong { color: var(--ink); font-weight: 600; } | |
| 73 | +.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } | |
| 74 | +.btn { display: inline-flex; align-items: center; justify-content: center; padding: 12px 20px; border-radius: 999px; font-weight: 600; font-size: .95rem; border: 1px solid var(--line-2); transition: transform .15s ease, background .15s ease; } | |
| 75 | +.btn:active { transform: scale(.97); } | |
| 76 | +.btn.primary { background: var(--grad); color: #06101a; border-color: transparent; } | |
| 77 | +.btn.ghost { background: var(--surface); } | |
| 78 | +.btn.ghost:hover { background: var(--surface-2); } | |
| 79 | + | |
| 80 | +.kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: clamp(36px, 6vw, 64px); } | |
| 81 | +.kpi { padding: 16px 16px 14px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; } | |
| 82 | +.kpi::after { content: ""; position: absolute; inset: auto 0 0 0; height: 2px; background: var(--grad); opacity: .7; } | |
| 83 | +.kpi-v { font-size: clamp(1.7rem, 5vw, 2.4rem); font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums; line-height: 1.05; } | |
| 84 | +.kpi-l { color: var(--ink-2); font-size: .9rem; margin-top: 2px; } | |
| 85 | +.kpi-s { color: var(--ink-3); font-size: .74rem; margin-top: 6px; } | |
| 86 | +@media (min-width: 640px) { .kpis { grid-template-columns: repeat(3, 1fr); } } | |
| 87 | +@media (min-width: 1000px) { .kpis { grid-template-columns: repeat(6, 1fr); } } | |
| 88 | + | |
| 89 | +/* flow */ | |
| 90 | +.flow { display: grid; grid-template-columns: 1fr; gap: 6px; } | |
| 91 | +.stage { padding: 20px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); } | |
| 92 | +.stage.accent { border-color: rgba(129,140,248,.45); background: linear-gradient(180deg, rgba(129,140,248,.10), rgba(45,212,191,.05)); } | |
| 93 | +.stage-ico { font-size: 1.5rem; margin-bottom: 10px; } | |
| 94 | +.stage h3 { font-size: 1.08rem; margin-bottom: 8px; } | |
| 95 | +.stage p { color: var(--ink-2); font-size: .93rem; } | |
| 96 | +.arrow { width: 2px; height: 22px; margin: 0 auto; background: linear-gradient(180deg, var(--teal), var(--indigo)); position: relative; opacity: .8; } | |
| 97 | +.arrow::after { content: ""; position: absolute; bottom: -1px; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top: 8px solid var(--indigo); } | |
| 98 | +@media (min-width: 1000px) { | |
| 99 | + .flow { grid-template-columns: 1fr 26px 1fr 26px 1fr 26px 1fr 26px 1fr; align-items: stretch; } | |
| 100 | + .arrow { width: 26px; height: 2px; margin: auto 0; align-self: center; background: linear-gradient(90deg, var(--teal), var(--indigo)); } | |
| 101 | + .arrow::after { bottom: auto; left: auto; right: -2px; top: 50%; transform: translateY(-50%); border: 6px solid transparent; border-left: 8px solid var(--indigo); } | |
| 102 | +} | |
| 103 | +.pillars { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 28px; } | |
| 104 | +.pillar { padding: 18px 20px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } | |
| 105 | +.pillar .pi { font-size: 1.3rem; } | |
| 106 | +.pillar h4 { font-size: 1rem; margin: 8px 0 6px; } | |
| 107 | +.pillar p { color: var(--ink-2); font-size: .92rem; } | |
| 108 | +@media (min-width: 720px) { .pillars { grid-template-columns: repeat(2, 1fr); } } | |
| 109 | +@media (min-width: 1100px) { .pillars { grid-template-columns: repeat(4, 1fr); } } | |
| 110 | + | |
| 111 | +/* toolbar */ | |
| 112 | +.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 18px; position: sticky; top: calc(56px + env(safe-area-inset-top)); z-index: 5; padding: 10px 0; background: linear-gradient(180deg, rgba(7,10,18,.92), rgba(7,10,18,.75)); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } | |
| 113 | +.chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 2px; } | |
| 114 | +.chips::-webkit-scrollbar { display: none; } | |
| 115 | +.chip { flex: 0 0 auto; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: .9rem; font-weight: 500; cursor: pointer; } | |
| 116 | +.chip .n { color: var(--ink-3); font-variant-numeric: tabular-nums; margin-left: 4px; } | |
| 117 | +.chip.active { background: var(--ink); color: #0a0f1c; border-color: transparent; } | |
| 118 | +.chip.active .n { color: #46506b; } | |
| 119 | +.sort { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--ink-3); font-size: .85rem; } | |
| 120 | +.sort select { font: inherit; font-size: .9rem; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; } | |
| 121 | + | |
| 122 | +.group-summary { display: none; gap: 10px; margin-bottom: 16px; color: var(--ink-2); font-size: .92rem; } | |
| 123 | +.group-summary.show { display: flex; flex-wrap: wrap; } | |
| 124 | +.group-summary span { padding: 6px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-variant-numeric: tabular-nums; } | |
| 125 | + | |
| 126 | +/* nodes */ | |
| 127 | +.nodes { display: grid; grid-template-columns: 1fr; gap: 12px; } | |
| 128 | +@media (min-width: 640px) { .nodes { grid-template-columns: repeat(2, 1fr); } } | |
| 129 | +@media (min-width: 1000px) { .nodes { grid-template-columns: repeat(3, 1fr); } } | |
| 130 | +.node { border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 12px; transition: border-color .2s, transform .2s; position: relative; } | |
| 131 | +.node:hover { border-color: var(--line-2); transform: translateY(-2px); } | |
| 132 | +.node.tier-ultra { background: linear-gradient(180deg, rgba(192,132,252,.09), var(--surface)); } | |
| 133 | +.node.tier-max { background: linear-gradient(180deg, rgba(129,140,248,.08), var(--surface)); } | |
| 134 | +.node.tier-epyc, .node.tier-ryzen { background: linear-gradient(180deg, rgba(45,212,191,.09), var(--surface)); } | |
| 135 | +.node.is-off { opacity: .72; } | |
| 136 | +.node-head { display: flex; align-items: flex-start; gap: 10px; } | |
| 137 | +.node-id { font-size: 1.2rem; font-weight: 800; letter-spacing: -.02em; font-family: "SF Mono", ui-monospace, Menlo, monospace; } | |
| 138 | +.node-model { color: var(--ink-2); font-size: .88rem; } | |
| 139 | +.status { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .74rem; font-weight: 600; border: 1px solid var(--line); white-space: nowrap; } | |
| 140 | +.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--off); } | |
| 141 | +.status.on { color: var(--good); border-color: rgba(52,211,153,.35); } | |
| 142 | +.status.on::before { background: var(--good); } | |
| 143 | +.status.off { color: var(--ink-3); } | |
| 144 | +.status.off::before { background: var(--off); } | |
| 145 | +.status.na { color: var(--ink-3); border-style: dashed; } | |
| 146 | +.badges { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 147 | +.badge { font-size: .72rem; padding: 3px 9px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); } | |
| 148 | +.badge.chip-ultra { color: #e9d5ff; border-color: rgba(192,132,252,.45); } | |
| 149 | +.badge.chip-max { color: #c7d2fe; border-color: rgba(129,140,248,.45); } | |
| 150 | +.badge.chip-pro { color: #a7f3d0; border-color: rgba(52,211,153,.35); } | |
| 151 | +.badge.chip-epyc, .badge.chip-ryzen { color: #99f6e4; border-color: rgba(45,212,191,.45); } | |
| 152 | +.badge.role { color: var(--teal); } | |
| 153 | +.specs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } | |
| 154 | +.spec { background: rgba(0,0,0,.25); border-radius: var(--r-s); padding: 8px 6px; text-align: center; } | |
| 155 | +.spec b { display: block; font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; } | |
| 156 | +.spec span { display: block; font-size: .66rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; } | |
| 157 | +.meters { display: grid; gap: 8px; } | |
| 158 | +.meter { display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 8px; font-size: .78rem; color: var(--ink-2); } | |
| 159 | +.meter .bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; } | |
| 160 | +.meter .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--teal); transition: width .6s ease; } | |
| 161 | +.meter .bar i.hi { background: var(--warn); } | |
| 162 | +.meter .bar i.crit { background: var(--bad); } | |
| 163 | +.meter .v { font-variant-numeric: tabular-nums; color: var(--ink); min-width: 4.5ch; text-align: right; } | |
| 164 | +.meter.na { color: var(--ink-3); } | |
| 165 | +.node-role { font-weight: 600; font-size: .95rem; } | |
| 166 | +.node-desc { color: var(--ink-2); font-size: .88rem; } | |
| 167 | +.node-apps { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 168 | +.node-apps a { font-size: .74rem; padding: 3px 9px; border-radius: 999px; background: rgba(45,212,191,.10); border: 1px solid rgba(45,212,191,.25); color: #99f6e4; } | |
| 169 | +.node-apps a.down { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.35); color: #fecaca; } | |
| 170 | +.node-foot { display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--ink-3); font-size: .76rem; margin-top: auto; padding-top: 4px; border-top: 1px dashed var(--line); } | |
| 171 | +.node-foot span { display: inline-flex; align-items: center; gap: 5px; } | |
| 172 | +.node-foot code { font-size: .74rem; } | |
| 173 | + | |
| 174 | +/* apps */ | |
| 175 | +.app-group { margin-bottom: 36px; } | |
| 176 | +.app-group-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px; margin-bottom: 14px; } | |
| 177 | +.app-group-head h3 { font-size: 1.35rem; } | |
| 178 | +.app-group-head .cnt { color: var(--ink-3); font-size: .9rem; font-variant-numeric: tabular-nums; } | |
| 179 | +.app-group-head p { flex-basis: 100%; color: var(--ink-2); font-size: .93rem; max-width: 760px; } | |
| 180 | +.apps-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } | |
| 181 | +@media (min-width: 640px) { .apps-grid { grid-template-columns: repeat(2, 1fr); } } | |
| 182 | +@media (min-width: 1000px) { .apps-grid { grid-template-columns: repeat(3, 1fr); } } | |
| 183 | +.app { display: block; padding: 14px 16px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); transition: border-color .2s, transform .2s; } | |
| 184 | +.app:hover { border-color: var(--line-2); transform: translateY(-2px); } | |
| 185 | +.app-top { display: flex; align-items: center; gap: 8px; } | |
| 186 | +.app-name { font-weight: 700; font-size: 1.02rem; letter-spacing: -.01em; } | |
| 187 | +.app-domain { color: var(--teal); font-size: .82rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; word-break: break-all; } | |
| 188 | +.app-domain.none { color: var(--ink-3); font-family: inherit; } | |
| 189 | +.app-desc { color: var(--ink-2); font-size: .88rem; margin-top: 8px; } | |
| 190 | +.app-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: center; } | |
| 191 | +.app-meta .badge.nodetag { font-family: "SF Mono", ui-monospace, Menlo, monospace; } | |
| 192 | +.app-meta .ms { color: var(--ink-3); font-size: .74rem; font-variant-numeric: tabular-nums; } | |
| 193 | + | |
| 194 | +/* tunnel */ | |
| 195 | +.gateways { display: grid; grid-template-columns: 1fr; gap: 12px; } | |
| 196 | +@media (min-width: 900px) { .gateways { grid-template-columns: repeat(2, 1fr); } } | |
| 197 | +.gw { padding: 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } | |
| 198 | +.gw.primary { border-color: rgba(45,212,191,.4); background: linear-gradient(180deg, rgba(45,212,191,.08), var(--surface)); } | |
| 199 | +.gw-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } | |
| 200 | +.gw-head h3 { font-size: 1.2rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; } | |
| 201 | +.gw-meta { color: var(--ink-2); font-size: .9rem; margin-top: 6px; } | |
| 202 | +.gw-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 14px 0; } | |
| 203 | +.peers { display: flex; flex-wrap: wrap; gap: 6px; } | |
| 204 | +.peer { font-size: .74rem; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink-2); font-family: "SF Mono", ui-monospace, Menlo, monospace; display: inline-flex; align-items: center; gap: 6px; } | |
| 205 | +.peer::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--off); } | |
| 206 | +.peer.on::before { background: var(--good); } | |
| 207 | +.peers-title { font-size: .78rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; } | |
| 208 | + | |
| 209 | +/* stack */ | |
| 210 | +.stack { display: grid; grid-template-columns: 1fr; gap: 10px; } | |
| 211 | +@media (min-width: 640px) { .stack { grid-template-columns: repeat(2, 1fr); } } | |
| 212 | +@media (min-width: 1000px) { .stack { grid-template-columns: repeat(3, 1fr); } } | |
| 213 | +.tool { padding: 16px 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } | |
| 214 | +.tool h4 { font-size: 1rem; margin-bottom: 6px; } | |
| 215 | +.tool p { color: var(--ink-2); font-size: .9rem; } | |
| 216 | + | |
| 217 | +/* footer */ | |
| 218 | +.foot { max-width: var(--maxw); margin: clamp(60px, 10vw, 120px) auto 0; padding: 28px var(--pad) calc(36px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); color: var(--ink-3); font-size: .85rem; display: flex; flex-direction: column; gap: 12px; } | |
| 219 | +.foot strong { color: var(--ink); } | |
| 220 | +.foot-links { display: flex; flex-wrap: wrap; gap: 8px 18px; } | |
| 221 | +.foot-links a { color: var(--ink-2); } | |
| 222 | +.foot-links a:hover { color: var(--ink); } | |
| 223 | + | |
| 224 | +.reveal { opacity: 0; transform: translateY(14px); transition: opacity .6s ease, transform .6s ease; } | |
| 225 | +.reveal.in { opacity: 1; transform: none; } | |
| 226 | +@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } } | |
added
server.mjs
+138 −0
@@ -0,0 +1,138 @@ | ||
| 1 | +// maclustr-www — site vitrine www.maclustr.io | |
| 2 | +// Serveur Node sans dépendance : fichiers statiques + /api/live (proxy filtré vers maclustr-agentd). | |
| 3 | +// Usage : node server.mjs [port] (env : AGENTD_URL, AGENTD_TOKEN, LIVE_TTL_S) | |
| 4 | + | |
| 5 | +import http from "node:http"; | |
| 6 | +import fs from "node:fs"; | |
| 7 | +import path from "node:path"; | |
| 8 | +import { fileURLToPath } from "node:url"; | |
| 9 | + | |
| 10 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 11 | +const PORT = Number(process.argv[2] || process.env.PORT || 8280); | |
| 12 | +const PUBLIC = path.join(__dirname, "public"); | |
| 13 | +const AGENTD_URL = (process.env.AGENTD_URL || "http://192.168.2.83:9210").replace(/\/$/, ""); | |
| 14 | +const AGENTD_TOKEN = process.env.AGENTD_TOKEN || ""; | |
| 15 | +const TTL_MS = Number(process.env.LIVE_TTL_S || 30) * 1000; | |
| 16 | + | |
| 17 | +const MIME = { | |
| 18 | + ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8", | |
| 19 | + ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", | |
| 20 | + ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2", | |
| 21 | +}; | |
| 22 | + | |
| 23 | +// ---------- live cache ---------- | |
| 24 | +let cache = { ts: 0, body: null, inflight: null }; | |
| 25 | + | |
| 26 | +async function agentd(pathname) { | |
| 27 | + const ctl = new AbortController(); | |
| 28 | + const t = setTimeout(() => ctl.abort(), 12000); | |
| 29 | + try { | |
| 30 | + const r = await fetch(AGENTD_URL + pathname, { headers: { Authorization: `Bearer ${AGENTD_TOKEN}` }, signal: ctl.signal }); | |
| 31 | + if (!r.ok) throw new Error(`agentd ${pathname} → ${r.status}`); | |
| 32 | + return await r.json(); | |
| 33 | + } finally { clearTimeout(t); } | |
| 34 | +} | |
| 35 | + | |
| 36 | +// Ne laisse passer que ce qui est public : pas d'IP, pas d'utilisateur, pas de chemin. | |
| 37 | +function shape(cluster, apps, tunnel) { | |
| 38 | + const nodes = {}; | |
| 39 | + const metrics = cluster?.metrics || {}; | |
| 40 | + for (const n of cluster?.nodes || []) { | |
| 41 | + const m = metrics[n.name] || {}; | |
| 42 | + const online = m.status === "online"; | |
| 43 | + nodes[n.name] = { | |
| 44 | + online, | |
| 45 | + cpu: online ? round(m.cpu, 1) : 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 | + diskUsedGB: online ? round(m.diskUsedGB, 0) : null, | |
| 50 | + diskTotalGB: online ? round(m.diskTotalGB, 0) : null, | |
| 51 | + uptimeS: online ? m.uptime : null, | |
| 52 | + os: online ? m.os : null, | |
| 53 | + thermal: online ? m.thermal : null, | |
| 54 | + gpu: online ? round(m.gpu, 0) : null, | |
| 55 | + apps: cluster?.appsByNode?.[n.name] || null, | |
| 56 | + }; | |
| 57 | + } | |
| 58 | + const appOut = {}; | |
| 59 | + const list = Array.isArray(apps?.apps) ? apps.apps : Object.values(apps?.apps || {}); | |
| 60 | + for (const a of list) { | |
| 61 | + appOut[a.app] = { | |
| 62 | + node: a.node, state: a.state, publicOk: a.public?.ok ?? null, publicMs: a.public?.ms ?? null, | |
| 63 | + localOk: a.local?.ok ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null, | |
| 64 | + }; | |
| 65 | + } | |
| 66 | + const gws = (tunnel?.gateways || []).map(g => ({ | |
| 67 | + name: g.name, ok: !!g.ok, primary: !!g.primary, site: g.site, | |
| 68 | + peers: (g.wg?.peers || []).map(p => ({ alias: p.alias, online: !!p.online, handshakeS: p.handshakeS ?? null, rx: p.rxBytes ?? 0, tx: p.txBytes ?? 0 })), | |
| 69 | + routes: Array.isArray(g.routes) ? g.routes.filter(r => r.kind === "proxy").length : null, | |
| 70 | + })); | |
| 71 | + const onlineCount = Object.values(nodes).filter(n => n.online).length; | |
| 72 | + return { | |
| 73 | + ts: Date.now(), agentVersion: cluster?.agentVersion || null, | |
| 74 | + summary: { | |
| 75 | + nodesTotal: Object.keys(nodes).length, nodesOnline: onlineCount, | |
| 76 | + apps: apps?.summary || null, | |
| 77 | + coresOnline: (cluster?.nodes || []).filter(n => nodes[n.name]?.online).reduce((s, n) => s + (n.cpuCores || 0), 0), | |
| 78 | + ramOnlineGB: (cluster?.nodes || []).filter(n => nodes[n.name]?.online).reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0), | |
| 79 | + ramUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.memUsedGB || 0), 0), 0), | |
| 80 | + }, | |
| 81 | + nodes, apps: appOut, tunnel: gws, | |
| 82 | + }; | |
| 83 | +} | |
| 84 | +const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null); | |
| 85 | + | |
| 86 | +async function live() { | |
| 87 | + if (cache.body && Date.now() - cache.ts < TTL_MS) return cache.body; | |
| 88 | + if (cache.inflight) return cache.inflight; | |
| 89 | + cache.inflight = (async () => { | |
| 90 | + try { | |
| 91 | + const [cluster, apps, tunnel] = await Promise.all([ | |
| 92 | + agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null), | |
| 93 | + ]); | |
| 94 | + cache.body = shape(cluster, apps, tunnel); | |
| 95 | + cache.ts = Date.now(); | |
| 96 | + return cache.body; | |
| 97 | + } catch (e) { | |
| 98 | + if (cache.body) return { ...cache.body, stale: true, error: String(e.message || e) }; | |
| 99 | + return { ts: Date.now(), error: String(e.message || e), nodes: {}, apps: {}, tunnel: [], summary: null }; | |
| 100 | + } finally { cache.inflight = null; } | |
| 101 | + })(); | |
| 102 | + return cache.inflight; | |
| 103 | +} | |
| 104 | + | |
| 105 | +// ---------- http ---------- | |
| 106 | +const server = http.createServer(async (req, res) => { | |
| 107 | + const url = new URL(req.url, "http://x"); | |
| 108 | + res.setHeader("X-Content-Type-Options", "nosniff"); | |
| 109 | + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); | |
| 110 | + | |
| 111 | + if (url.pathname === "/healthz") return send(res, 200, "ok\n", "text/plain"); | |
| 112 | + if (url.pathname === "/api/live") { | |
| 113 | + const body = await live(); | |
| 114 | + res.setHeader("Cache-Control", "public, max-age=15"); | |
| 115 | + return send(res, body.error && !body.summary ? 503 : 200, JSON.stringify(body), MIME[".json"]); | |
| 116 | + } | |
| 117 | + | |
| 118 | + let p = decodeURIComponent(url.pathname); | |
| 119 | + if (p === "/") p = "/index.html"; | |
| 120 | + const file = path.normalize(path.join(PUBLIC, p)); | |
| 121 | + if (!file.startsWith(PUBLIC)) return send(res, 403, "forbidden", "text/plain"); | |
| 122 | + fs.stat(file, (err, st) => { | |
| 123 | + if (err || !st.isFile()) { | |
| 124 | + return fs.readFile(path.join(PUBLIC, "index.html"), (e2, buf) => e2 ? send(res, 404, "not found", "text/plain") : send(res, 200, buf, MIME[".html"])); | |
| 125 | + } | |
| 126 | + const ext = path.extname(file).toLowerCase(); | |
| 127 | + res.setHeader("Cache-Control", ext === ".html" ? "no-cache" : "public, max-age=3600"); | |
| 128 | + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": st.size }); | |
| 129 | + fs.createReadStream(file).pipe(res); | |
| 130 | + }); | |
| 131 | +}); | |
| 132 | + | |
| 133 | +function send(res, code, body, type) { | |
| 134 | + res.writeHead(code, { "Content-Type": type }); | |
| 135 | + res.end(body); | |
| 136 | +} | |
| 137 | + | |
| 138 | +server.listen(PORT, "0.0.0.0", () => console.log(`maclustr-www :${PORT} → agentd ${AGENTD_URL} (token ${AGENTD_TOKEN ? "ok" : "ABSENT"})`)); | |
| 139 | ||