SPB Git

spb/maclustr-www

Public

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>
Simon-Pierre Boucher committed (Sep 11, 2026) Browse files

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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 &amp; 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
Menu

Theme