SPB Git forge

spb/maclustr-www

Public
1commits 1branches 0releases
60.0 KBsize
maindefault branch
13 days agolast push
JavaScript 58% CSS 24.8% HTML 17.1%

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 13 days ago (Sep 11, 2026)

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