SPB Git

spb/maclustr-www

Public

maclustr-www v2 — multi-pages : accueil live, page par nœud (historique 24 h), carte mondiale MapLibre, contact, design épuré

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 8031ce2 Browse files

5 changed files +1,059 −697

modified public/app.js +560 −225
@@ -1,259 +1,594 @@
1−/* MacLustr — rendu de l'inventaire + superposition live (/api/live toutes les 30 s) */ 1+/* MacLustr v2 — routeur multi-pages + superposition live (/api/live toutes les 30 s) */
2 (function () { 2 (function () {
3 const D = window.MACLUSTR; 3 const D = window.MACLUSTR;
4 const $ = (s, r = document) => r.querySelector(s); 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; }; 5+ const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
6 const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); 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); 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)" }; 8+ const fmt1 = n => new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1 }).format(n);
9− 9+ const GROUP_LABEL = { lan: "LAN Saint-Augustin", rented: "Mac dédié loué", ovh: "OVHcloud · Linux" };
10− let live = null; 10+ const GROUP_SHORT = { lan: "LAN", rented: "Loué", ovh: "OVH" };
11− let filter = "all"; 11+ const view = $("#view");
12− let sortKey = "cores"; 12+ const byId = Object.fromEntries(D.nodes.map(n => [n.id, n]));
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])); 13 const appById = Object.fromEntries(D.apps.map(a => [a.id, a]));
14+ const appsByNode = {}; D.apps.forEach(a => (appsByNode[a.node] ||= []).push(a));
15+ const nodesBySite = {}; D.nodes.forEach(n => (nodesBySite[n.site] ||= []).push(n));
16 16
17− // ---------- KPIs ---------- 17+ let live = null; // dernier /api/live
18− function renderKPIs() { 18+ let page = null; // { name, patch(), destroy() }
19+ const totals = (() => {
19 const macs = D.nodes.filter(n => n.group !== "ovh"); 20 const macs = D.nodes.filter(n => n.group !== "ovh");
20− const cores = D.nodes.reduce((s, n) => s + n.cores, 0); 21+ return {
21− const gpu = macs.reduce((s, n) => s + (n.gpu || 0), 0); 22+ nodes: D.nodes.length, macs: macs.length, ovh: D.nodes.length - macs.length,
22− const ram = D.nodes.reduce((s, n) => s + n.ram, 0); 23+ cores: D.nodes.reduce((s, n) => s + n.cores, 0), gpu: macs.reduce((s, n) => s + (n.gpu || 0), 0),
23− const set = (k, v) => { const e = $(`[data-kpi="${k}"]`); if (e) e.textContent = v; }; 24+ ram: D.nodes.reduce((s, n) => s + n.ram, 0), disk: D.nodes.reduce((s, n) => s + (n.disk || 0), 0),
24− set("nodes", D.nodes.length); 25+ apps: D.apps.length, sites: D.apps.filter(a => a.domain).length, places: Object.keys(D.sites).length,
25− set("cores", fmt(cores)); 26+ countries: new Set(Object.values(D.sites).map(s => s.region.split(",").pop().trim())).size,
26− set("gpu", fmt(gpu)); 27+ };
27− set("ram", ram >= 1000 ? (ram / 1024).toFixed(2).replace(".", ",") + " To" : ram + " Go"); 28+ })();
28− set("apps", D.apps.length); 29+ const ramFmt = gb => gb >= 1000 ? fmt1(gb / 1024) + " To" : gb + " Go";
29− set("sites", D.apps.filter(a => a.domain).length); 30+ const diskFmt = gb => gb ? (gb >= 1000 ? fmt1(gb / 1000) + " To" : gb + " Go") : "—";
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; 31 const pct = (a, b) => (a != null && b) ? Math.min(100, Math.round(a / b * 100)) : null;
108− function setMeter(card, key, p, label) { 32+ const uptime = s => { if (!s) return "—"; const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); return d ? `${d} j ${h} h` : h ? `${h} h ${m} min` : `${m} min`; };
109− const m = card.querySelector(`[data-m="${key}"]`); if (!m) return; 33+ const bytes = b => b >= 1e12 ? fmt1(b / 1e12) + " To" : b >= 1e9 ? fmt1(b / 1e9) + " Go" : fmt(Math.round(b / 1e6)) + " Mo";
34+ const kbs = k => k == null ? "—" : k >= 1024 ? fmt1(k / 1024) + " Mo/s" : fmt(k) + " ko/s";
35+ const liveNode = id => live?.nodes?.[id] || null;
36+ const liveApp = id => live?.apps?.[id] || null;
37+ const gwOf = id => live?.tunnel?.find(g => g.name === id) || null;
38+ const peerOf = id => { for (const g of live?.tunnel || []) { const p = g.peers.find(p => p.alias === id); if (p) return { ...p, gw: g.name }; } return null; };
39+
40+ // ---------- helpers UI ----------
41+ const statusOf = n => {
42+ if (n.group === "ovh") { const g = gwOf(n.id); if (g) return g.ok ? ["on", "passerelle active"] : ["bad", "injoignable"]; return ["on", n.tags.includes("gateway") ? "passerelle" : "en réserve"]; }
43+ if (!live || live.dead) return ["na", live?.dead ? "inconnu" : "…"];
44+ const l = liveNode(n.id); if (!l || !l.online) return ["off", "hors ligne"];
45+ if (l.cpu >= 90 || pct(l.memUsedGB, l.memTotalGB) >= 92) return ["warn", "chargé"];
46+ return ["on", "en ligne"];
47+ };
48+ const statusHTML = (n) => { const [c, t] = statusOf(n); return `<span class="status ${c}" data-status="${esc(n.id)}">${t}</span>`; };
49+ const meterHTML = (key, label) => `<div class="meter na" data-m="${key}"><span>${label}</span><div class="bar"><i></i></div><span class="v">—</span></div>`;
50+ function setMeter(root, key, p, label) {
51+ const m = root.querySelector(`[data-m="${key}"]`); if (!m) return;
110 const bar = m.querySelector("i"), v = m.querySelector(".v"); 52 const bar = m.querySelector("i"), v = m.querySelector(".v");
111 if (p == null) { m.classList.add("na"); bar.style.width = "0"; v.textContent = "—"; return; } 53 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; 54 m.classList.remove("na"); bar.style.width = p + "%"; bar.className = p >= 90 ? "crit" : p >= 75 ? "hi" : ""; v.textContent = label;
113 } 55 }
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`; } 56+ function patchMeters(root, id) {
115− function patchNodesLive() { 57+ const l = liveNode(id);
116− document.querySelectorAll(".node").forEach(card => { 58+ if (!l || !l.online) { ["cpu", "ram", "disk"].forEach(k => setMeter(root, k, null)); return; }
117− const n = D.nodes.find(x => x.id === card.dataset.id); 59+ setMeter(root, "cpu", Math.round(l.cpu ?? 0), `${Math.round(l.cpu ?? 0)} %`);
118− const st = card.querySelector("[data-status]"), extra = card.querySelector("[data-extra]"); 60+ setMeter(root, "ram", pct(l.memUsedGB, l.memTotalGB), `${fmt1(l.memUsedGB)} / ${l.memTotalGB} Go`);
119− if (n.group === "ovh") { 61+ setMeter(root, "disk", pct(l.diskUsedGB, l.diskTotalGB), `${fmt(l.diskUsedGB)} / ${fmt(l.diskTotalGB)} Go`);
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 } 62 }
63+ function patchStatuses(root) {
64+ $$("[data-status]", root).forEach(el => { const n = byId[el.dataset.status]; if (!n) return; const [c, t] = statusOf(n); el.className = `status ${c}`; el.textContent = t; });
65+ }
66+ const chipClass = t => ({ ultra: "accent", max: "accent", pro: "", base: "", epyc: "accent", ryzen: "accent" }[t] || "");
67+ const appStatus = a => {
68+ const l = liveApp(a.id); if (!live || live.dead) return ["na", live?.dead ? "inconnu" : "…"]; if (!l) return ["na", "inconnu"];
69+ return l.state === "up" ? ["on", "en ligne"] : l.state === "degraded" ? ["warn", "dégradée"] : l.state === "down" ? ["bad", "hors service"] : ["off", l.state || "—"];
70+ };
147 71
148− // ---------- Apps ---------- 72+ // tooltip global
149− function renderApps() { 73+ const tip = document.createElement("div"); tip.className = "tip"; document.body.appendChild(tip);
150− const host = $("#appGroups"); host.innerHTML = ""; 74+ function showTip(html, x, y) { tip.innerHTML = html; tip.classList.add("show"); const w = tip.offsetWidth, h = tip.offsetHeight; tip.style.left = Math.min(x + 12, window.innerWidth - w - 8) + "px"; tip.style.top = (y - h - 10 < 8 ? y + 14 : y - h - 10) + "px"; }
151− D.appGroups.forEach(g => { 75+ function hideTip() { tip.classList.remove("show"); }
152− const apps = D.apps.filter(a => a.group === g.id); 76+
153− const sec = el("div", "app-group"); 77+ // ---------- pages ----------
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>`; 78+ const pages = {};
155− const grid = el("div", "apps-grid"); 79+
156− apps.forEach(a => { 80+ // ===== Accueil =====
157− const card = el(a.domain ? "a" : "div", "app"); 81+ pages.home = () => {
158− card.id = `app-${a.id}`; card.dataset.app = a.id; 82+ view.innerHTML = `
159− if (a.domain) { card.href = `https://${a.domain}/`; card.target = "_blank"; card.rel = "noopener"; } 83+ <section class="hero fade">
160− card.innerHTML = ` 84+ <p class="eyebrow">Cluster privé · Apple Silicon · Québec</p>
161− <div class="app-top"><span class="app-name">${esc(a.name)}</span><span class="status na" data-status>—</span></div> 85+ <h1>Un supercalculateur<br><span class="dim">fait de Macs.</span></h1>
162− <div class="app-domain ${a.domain ? "" : "none"}">${a.domain ? esc(a.domain) : "service interne"}</div> 86+ <p class="lede">MacLustr réunit <strong>${totals.macs} Macs Apple Silicon</strong> et <strong>${totals.ovh} serveurs OVHcloud</strong>, répartis dans <strong>${totals.places} emplacements</strong> sur trois continents, en une seule plateforme : orchestration automatique, tunnel chiffré, supervision continue. Elle héberge <strong>${totals.apps} applications</strong> en production.</p>
163− <p class="app-desc">${esc(a.desc)}</p> 87+ <div class="cta"><a class="btn primary" href="/nodes" data-link>Explorer les nœuds</a><a class="btn" href="/map" data-link>Voir la carte</a></div>
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>`; 88+ </section>
165− grid.appendChild(card); 89+
166− }); 90+ <section class="section tight">
167− sec.appendChild(grid); host.appendChild(sec); 91+ <div class="facts">
92+ <div class="fact"><b class="num">${totals.nodes}</b><span>nœuds</span><small data-f="nodes"></small></div>
93+ <div class="fact"><b class="num">${fmt(totals.cores)}</b><span>cœurs CPU</span><small data-f="cores"></small></div>
94+ <div class="fact"><b class="num">${fmt(totals.gpu)}</b><span>cœurs GPU</span><small data-f="gpu"></small></div>
95+ <div class="fact"><b class="num">${ramFmt(totals.ram)}</b><span>mémoire</span><small data-f="ram"></small></div>
96+ <div class="fact"><b class="num">${totals.apps}</b><span>applications</span><small data-f="apps"></small></div>
97+ <div class="fact"><b class="num">${totals.sites}</b><span>sites HTTPS</span><small data-f="sites"></small></div>
98+ </div>
99+ </section>
100+
101+ <section class="section">
102+ <div class="sec-head"><div><p class="eyebrow">En direct</p><h2>État du cluster</h2><p class="sub">Agrégats calculés sur les ${totals.macs} Macs à partir de maclustr-agentd, rafraîchis toutes les 30 secondes.</p></div><span class="more mono" id="liveTs"></span></div>
103+ <div class="livegrid">
104+ <div class="panel">
105+ <h3>Charge par nœud <span class="mono">CPU · ${totals.macs} Macs</span></h3>
106+ <div class="heat" id="heat">${D.nodes.filter(n => n.group !== "ovh").map(n => `<a class="cell off" href="/nodes/${n.id}" data-link data-cell="${n.id}"><i></i><span>${esc(n.id)}</span></a>`).join("")}</div>
107+ <div class="heat-legend"><span>0 %</span><i></i><span>100 % CPU</span><span style="margin-left:auto">hachuré = hors ligne</span></div>
108+ </div>
109+ <div class="panel">
110+ <h3>Totaux live <span class="mono" id="liveAgent"></span></h3>
111+ <div class="bigstat">
112+ <div><b class="num" data-s="online">—</b><span>Macs en ligne</span></div>
113+ <div><b class="num" data-s="cpu">—</b><span>CPU moyen</span></div>
114+ <div><b class="num" data-s="apps">—</b><span>apps saines</span></div>
115+ <div><b class="num" data-s="cores">—</b><span>cœurs actifs</span></div>
116+ <div><b class="num" data-s="procs">—</b><span>processus</span></div>
117+ <div><b class="num" data-s="net">—</b><span>trafic entrant</span></div>
118+ </div>
119+ <div class="rambar"><div class="lbl"><span>Mémoire utilisée</span><b data-s="ramlbl">—</b></div><div class="bar"><i data-s="rambar"></i></div></div>
120+ <div class="rambar"><div class="lbl"><span>Disque utilisé</span><b data-s="disklbl">—</b></div><div class="bar"><i data-s="diskbar"></i></div></div>
121+ <div class="rambar"><div class="lbl"><span>Tunnel · trafic cumulé vers les passerelles</span><b data-s="tunnel">—</b></div></div>
122+ </div>
123+ </div>
124+ </section>
125+
126+ <section class="section">
127+ <div class="sec-head"><div><p class="eyebrow">Emplacements</p><h2>${totals.places} sites, ${totals.countries} pays</h2><p class="sub">Le LAN de Saint-Augustin, deux centres OVHcloud et quatre hébergeurs de Macs dédiés.</p></div><a class="more" href="/map" data-link>Carte mondiale →</a></div>
128+ <div class="sites-strip">${Object.entries(D.sites).map(([k, s]) => siteCard(k, s)).join("")}</div>
129+ </section>
130+
131+ <section class="section">
132+ <div class="sec-head"><div><p class="eyebrow">Architecture</p><h2>Du portable au monde, en cinq étapes</h2><p class="sub">Une commande, <code>mld deploy</code>, choisit le nœud, synchronise, démarre, publie la route et vérifie la santé. Rien n'est placé à la main.</p></div></div>
133+ <div class="steps">
134+ <div class="step"><div class="n">01</div><h3>Poste de travail</h3><p>Le code est mis en scène vers la passerelle. Claude Code pilote la plupart des opérations.</p></div>
135+ <div class="step"><div class="n">02</div><h3>Passerelle M1M32</h3><p>Forge git privée (spbgit) et orchestrateur mld : scan live, score, rsync, PM2, registre, auto-réparation toutes les 5 minutes.</p></div>
136+ <div class="step"><div class="n">03</div><h3>${totals.macs} nœuds Mac</h3><p>21 Macs sur le LAN, 12 Macs dédiés loués à Istanbul, Atlanta, Dublin et Limassol. Chaque app tourne sur le nœud le plus efficient.</p></div>
137+ <div class="step"><div class="n">04</div><h3>WireGuard</h3><p>Chaque nœud tient deux tunnels chiffrés permanents vers les passerelles. Aucun port ouvert sur le LAN.</p></div>
138+ <div class="step"><div class="n">05</div><h3>Passerelles OVHcloud</h3><p>Caddy termine le TLS à Beauharnois (≈ 10 ms) ou Gravelines et route chaque domaine vers son nœud.</p></div>
139+ </div>
140+ </section>
141+
142+ <section class="section">
143+ <div class="sec-head"><div><p class="eyebrow">Hébergement</p><h2>${totals.apps} applications en production</h2><p class="sub">Groupe Ka, données de marché, index scientifiques, outils d'enseignement, services de la plateforme.</p></div><a class="more" href="/apps" data-link>Toutes les applications →</a></div>
144+ <div class="grid c3">${D.appGroups.map(g => { const apps = D.apps.filter(a => a.group === g.id); return `<a class="card pad" href="/apps#${g.id}" data-link><div style="display:flex;justify-content:space-between;align-items:baseline;gap:8px"><h3 style="font-size:1.05rem">${esc(g.name)}</h3><span class="mono faint" style="font-size:.8rem">${apps.length}</span></div><p class="muted" style="font-size:.88rem;margin-top:8px">${esc(g.blurb)}</p><div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:12px">${apps.slice(0, 6).map(a => `<span class="tag">${esc(a.name)}</span>`).join("")}${apps.length > 6 ? `<span class="tag">+${apps.length - 6}</span>` : ""}</div></a>`; }).join("")}</div>
145+ </section>`;
146+
147+ const cells = Object.fromEntries($$("[data-cell]").map(c => [c.dataset.cell, c]));
148+ Object.values(cells).forEach(c => {
149+ c.addEventListener("mousemove", e => { const l = liveNode(c.dataset.cell); const n = byId[c.dataset.cell]; showTip(`<b>${esc(n.id)}</b> · ${esc(n.chip)}<div class="row"><span>État</span><span>${l?.online ? "en ligne" : "hors ligne"}</span></div>${l?.online ? `<div class="row"><span>CPU</span><span>${Math.round(l.cpu)} %</span></div><div class="row"><span>RAM</span><span>${fmt1(l.memUsedGB)} / ${l.memTotalGB} Go</span></div><div class="row"><span>Charge</span><span>${l.load1}</span></div>` : ""}`, e.clientX, e.clientY); });
150+ c.addEventListener("mouseleave", hideTip);
168 }); 151 });
169− patchAppsLive(); 152+ const set = (k, v) => { const e = $(`[data-s="${k}"]`); if (e) e.textContent = v; };
153+ const setF = (k, v) => { const e = $(`[data-f="${k}"]`); if (e) e.textContent = v; };
154+ const patch = () => {
155+ patchStatuses(view);
156+ $$(".site-card .nodes span").forEach(s => { const l = liveNode(s.dataset.n); s.classList.toggle("on", !!l?.online || byId[s.dataset.n]?.group === "ovh"); });
157+ if (!live?.summary) return;
158+ const s = live.summary;
159+ $("#liveTs").textContent = new Date(live.ts).toLocaleTimeString("fr-CA") + (live.stale ? " · cache" : "");
160+ $("#liveAgent").textContent = live.agentVersion ? `agentd ${live.agentVersion}` : "";
161+ set("online", `${s.nodesOnline}/${s.nodesTotal}`); set("cpu", s.cpuAvg != null ? `${fmt1(s.cpuAvg)} %` : "—");
162+ set("apps", s.apps ? `${s.apps.up}/${s.apps.total}` : "—"); set("cores", fmt(s.coresOnline)); set("procs", fmt(s.procs));
163+ set("net", kbs(s.netInKBs));
164+ set("ramlbl", `${fmt(s.ramUsedGB)} / ${fmt(s.ramOnlineGB)} Go`); $(`[data-s="rambar"]`).style.width = pct(s.ramUsedGB, s.ramOnlineGB) + "%";
165+ set("disklbl", `${fmt(s.diskUsedGB)} / ${fmt(s.diskTotalGB)} Go`); $(`[data-s="diskbar"]`).style.width = pct(s.diskUsedGB, s.diskTotalGB) + "%";
166+ set("tunnel", `${bytes(s.tunnelRx)} ↑ · ${bytes(s.tunnelTx)} ↓`);
167+ setF("nodes", `${s.nodesOnline + D.nodes.filter(n => n.group === "ovh").length} en ligne`); setF("cores", `${fmt(s.coresOnline)} actifs`); setF("gpu", `${fmt(s.gpuOnline)} actifs`);
168+ setF("ram", `${fmt(s.ramUsedGB)} Go utilisés`); setF("apps", s.apps ? `${s.apps.up} saines` : ""); setF("sites", `via ${live.tunnel.filter(g => g.ok).length} passerelles`);
169+ Object.entries(cells).forEach(([id, c]) => { const l = liveNode(id); const on = !!l?.online; c.classList.toggle("off", !on); c.querySelector("i").style.opacity = on ? (0.12 + 0.88 * Math.min(1, (l.cpu || 0) / 100)).toFixed(2) : 0; });
170+ };
171+ patch();
172+ return { patch };
173+ };
174+ function siteCard(k, s) {
175+ const ns = nodesBySite[k] || [];
176+ return `<a class="card site-card" href="/map?site=${k}" data-link><div class="where"><span>${esc(s.name)}</span><span class="cnt">${ns.length} nœud${ns.length > 1 ? "s" : ""}</span></div><div class="op">${esc(s.region)} · ${esc(s.operator)}</div><div class="nodes">${ns.map(n => `<span data-n="${n.id}">${esc(n.id)}</span>`).join("")}</div></a>`;
170 } 177 }
171− function patchAppsLive() { 178+
172− document.querySelectorAll(".app").forEach(card => { 179+ // ===== Nœuds (liste) =====
173− const a = appById[card.dataset.app]; const l = live?.apps?.[a.id]; 180+ pages.nodes = () => {
174− const st = card.querySelector("[data-status]"), ms = card.querySelector("[data-ms]"), nd = card.querySelector("[data-node]"); 181+ let filter = "all", sortKey = "cores", q = "";
182+ view.innerHTML = `
183+ <section class="page-head fade">
184+ <p class="eyebrow">Inventaire</p>
185+ <h1>${totals.nodes} nœuds, un par un.</h1>
186+ <p class="lede">Trois familles : le LAN de Saint-Augustin, les Macs dédiés loués hors LAN, et quatre serveurs Linux OVHcloud. Chaque nœud a sa page, avec ses métriques des dernières 24 heures.</p>
187+ </section>
188+ <div class="toolbar">
189+ <div class="chips" role="tablist">
190+ ${[["all", "Tous", D.nodes.length], ["lan", "LAN", nodesCount("lan")], ["rented", "Macs loués", nodesCount("rented")], ["ovh", "OVHcloud", nodesCount("ovh")]].map(([k, l, n]) => `<button class="chip ${k === "all" ? "active" : ""}" data-filter="${k}" role="tab">${l}<span class="n">${n}</span></button>`).join("")}
191+ </div>
192+ <input class="search" id="q" type="search" placeholder="Rechercher un nœud, une puce…" aria-label="Rechercher">
193+ <label class="sort">Trier <select id="sortSel"><option value="cores">cœurs</option><option value="ram">RAM</option><option value="name">nom</option><option value="load">charge live</option></select></label>
194+ </div>
195+ <div class="group-sum" id="gsum"></div>
196+ <div class="rows-head"><span>Nœud</span><span>Puce</span><span>Ressources</span><span>Live</span><span>État</span><span></span></div>
197+ <div class="rows" id="rows"></div>`;
198+ const rows = $("#rows");
199+ const list = () => {
200+ let l = D.nodes.filter(n => filter === "all" || n.group === filter);
201+ if (q) { const s = q.toLowerCase(); l = l.filter(n => [n.id, n.chip, n.model, n.role, n.provider, n.location].join(" ").toLowerCase().includes(s)); }
202+ 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: (a, b) => ((liveNode(b.id)?.online ? liveNode(b.id).cpu ?? -1 : -2) - (liveNode(a.id)?.online ? liveNode(a.id).cpu ?? -1 : -2)) }[sortKey];
203+ return l.sort(cmp);
204+ };
205+ const render = () => {
206+ const l = list();
207+ rows.innerHTML = l.length ? l.map(n => `
208+ <a class="row" href="/nodes/${n.id}" data-link data-row="${n.id}">
209+ <div><div class="id">${esc(n.id)}</div><div class="model">${esc(n.model)} · ${GROUP_SHORT[n.group]}</div></div>
210+ <div class="chipcol"><span class="tag ${chipClass(n.tier)}">${esc(n.chip)}</span></div>
211+ <div class="spec"><span><b>${n.cores}</b> ${n.threads ? "thr" : "c"}</span>${n.gpu ? `<span><b>${n.gpu}</b> GPU</span>` : ""}<span><b>${n.ram}</b> Go</span><span><b>${diskFmt(n.disk)}</b></span></div>
212+ ${n.group !== "ovh" ? `<div class="meters">${meterHTML("cpu", "CPU")}${meterHTML("ram", "RAM")}</div>` : `<div class="meters muted" style="font-size:.8rem">${esc(n.role)}</div>`}
213+ <div class="st">${statusHTML(n)}</div>
214+ <div class="arrow">→</div>
215+ <div class="site">${esc(D.sites[n.site]?.name || "")} · ${esc(n.role)}</div>
216+ </a>`).join("") : `<div class="empty">Aucun nœud ne correspond.</div>`;
217+ const cores = l.reduce((s, n) => s + n.cores, 0), ram = l.reduce((s, n) => s + n.ram, 0), gpu = l.reduce((s, n) => s + (n.gpu || 0), 0);
218+ const on = l.filter(n => n.group === "ovh" || liveNode(n.id)?.online).length;
219+ $("#gsum").innerHTML = `<span><b>${l.length}</b> nœuds</span><span><b>${fmt(cores)}</b> ${filter === "ovh" ? "threads" : "cœurs"}</span>${gpu ? `<span><b>${fmt(gpu)}</b> cœurs GPU</span>` : ""}<span><b>${ramFmt(ram)}</b> RAM</span>${live ? `<span><b>${on}</b> en ligne</span>` : ""}`;
220+ patch();
221+ };
222+ const patch = () => { patchStatuses(rows); $$("[data-row]", rows).forEach(r => patchMeters(r, r.dataset.row)); if (sortKey === "load" && live) { /* re-tri à la prochaine interaction */ } };
223+ $$(".chip[data-filter]").forEach(b => b.addEventListener("click", () => { $$(".chip[data-filter]").forEach(x => x.classList.remove("active")); b.classList.add("active"); filter = b.dataset.filter; render(); }));
224+ $("#sortSel").addEventListener("change", e => { sortKey = e.target.value; render(); });
225+ $("#q").addEventListener("input", e => { q = e.target.value.trim(); render(); });
226+ render();
227+ return { patch: () => { if (sortKey === "load") render(); else patch(); } };
228+ };
229+ const nodesCount = g => D.nodes.filter(n => n.group === g).length;
230+
231+ // ===== Fiche nœud =====
232+ pages.node = (id) => {
233+ const n = byId[id] || byId[Object.keys(byId).find(k => k.toLowerCase() === String(id).toLowerCase())];
234+ if (!n) return pages.notFound(`Nœud « ${esc(id)} » inconnu.`);
235+ const site = D.sites[n.site];
236+ const apps = appsByNode[n.id] || [];
237+ const idx = D.nodes.indexOf(n), prev = D.nodes[(idx - 1 + D.nodes.length) % D.nodes.length], next = D.nodes[(idx + 1) % D.nodes.length];
238+ const isMac = n.group !== "ovh";
239+ let win = "24h";
240+ view.innerHTML = `
241+ <section class="page-head fade" style="padding-top:clamp(22px,4vw,40px)">
242+ <div class="crumbs"><a href="/" data-link>Accueil</a><span>/</span><a href="/nodes" data-link>Nœuds</a><span>/</span><span>${esc(n.id)}</span></div>
243+ <div class="node-hero">
244+ <div>
245+ <div class="node-title"><h1>${esc(n.id)}</h1>${statusHTML(n)}</div>
246+ <p class="node-sub">${esc(n.model)} · ${esc(n.chip)} · ${esc(n.role)}</p>
247+ <div class="node-tags"><span class="tag ${chipClass(n.tier)}">${esc(n.chip)}</span><span class="tag">${GROUP_LABEL[n.group]}</span>${n.provider ? `<span class="tag">${esc(n.provider)}</span>` : ""}<a class="tag" href="/map?site=${n.site}" data-link>${esc(site?.name || "")}, ${esc(site?.region || "")}</a>${n.tags.filter(t => !["worker", "distant", "linux"].includes(t)).map(t => `<span class="tag">${esc(t)}</span>`).join("")}</div>
248+ </div>
249+ <div class="node-actions"><a class="btn small" href="/map?site=${n.site}" data-link>Voir sur la carte</a>${apps.length ? `<a class="btn small" href="#apps-${n.id}">${apps.length} app${apps.length > 1 ? "s" : ""}</a>` : ""}</div>
250+ </div>
251+ <div class="specs">
252+ <div><b class="num">${n.cores}</b><span>${n.threads ? "threads" : "cœurs CPU"}</span></div>
253+ <div><b class="num">${n.gpu || "—"}</b><span>cœurs GPU</span></div>
254+ <div><b class="num">${n.ram} Go</b><span>mémoire</span></div>
255+ <div><b class="num">${diskFmt(n.disk)}</b><span>stockage</span></div>
256+ <div><b class="num" data-d="os">—</b><span>${isMac ? "macOS" : "système"}</span></div>
257+ <div><b class="num" data-d="uptime">—</b><span>uptime</span></div>
258+ </div>
259+ </section>
260+
261+ <section class="section tight">
262+ <div class="detail-grid">
263+ <div style="display:flex;flex-direction:column;gap:10px">
264+ ${isMac ? `
265+ <div class="panel">
266+ <h3>Utilisation en direct <span class="mono" data-d="ts"></span></h3>
267+ <div style="display:grid;gap:10px">${meterHTML("cpu", "CPU")}${meterHTML("ram", "RAM")}${meterHTML("disk", "Disque")}</div>
268+ <div class="kv" style="margin-top:16px"><dt>Charge (1 min)</dt><dd data-d="load">—</dd><dt>Processus</dt><dd data-d="procs">—</dd><dt>Réseau</dt><dd data-d="net">—</dd><dt>Swap</dt><dd data-d="swap">—</dd><dt>Thermique</dt><dd data-d="thermal">—</dd></div>
269+ </div>
270+ <div class="panel">
271+ <h3>Historique <span class="seg" id="winSeg"><button data-w="1h">1 h</button><button data-w="6h">6 h</button><button data-w="24h" class="active">24 h</button></span></h3>
272+ <div class="chart-wrap"><div class="chart-head"><span>CPU</span><b id="cpuNow"></b></div><div id="chartCpu"></div></div>
273+ <div class="chart-wrap" style="margin-top:16px"><div class="chart-head"><span>Mémoire utilisée</span><b id="memNow"></b></div><div id="chartMem"></div></div>
274+ <div class="chart-wrap" style="margin-top:16px"><div class="chart-head"><span>Réseau entrant</span><b id="netNow"></b></div><div id="chartNet"></div></div>
275+ </div>` : `
276+ <div class="panel">
277+ <h3>Passerelle <span class="mono" data-d="ts"></span></h3>
278+ <div class="bigstat"><div><b class="num" data-d="routes">—</b><span>routes HTTPS</span></div><div><b class="num" data-d="peers">—</b><span>pairs WireGuard</span></div><div><b class="num" data-d="traffic">—</b><span>trafic reçu</span></div></div>
279+ <div id="peerList" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:16px"></div>
280+ </div>`}
281+ <div class="panel">
282+ <h3>Rôle</h3>
283+ <p class="prose"><strong>${esc(n.role)}.</strong> ${esc(n.desc)}</p>
284+ </div>
285+ </div>
286+ <div style="display:flex;flex-direction:column;gap:10px">
287+ <div class="panel" id="apps-${n.id}">
288+ <h3>Applications hébergées <span class="mono">${apps.length}</span></h3>
289+ ${apps.length ? `<div class="applist">${apps.map(a => `<a href="/apps#${a.id}" data-link data-app="${a.id}"><span class="status na" data-appst="${a.id}">…</span><span class="nm">${esc(a.name)}</span><span class="dm">${a.domain ? esc(a.domain) : ":" + a.port}</span></a>`).join("")}</div>` : `<p class="muted" style="font-size:.9rem">Aucune application placée ici pour l'instant${n.group === "rented" ? " : nœud réservé aux déploiements explicites." : n.tags.includes("gateway") ? " : rôle d'infrastructure." : "."}</p>`}
290+ </div>
291+ <div class="panel">
292+ <h3>Réseau</h3>
293+ <dl class="kv">
294+ ${n.ip ? `<dt>IP LAN</dt><dd>${esc(n.ip)}</dd>` : ""}
295+ <dt>Emplacement</dt><dd>${esc(site?.name || "")}, ${esc(site?.region || "")}</dd>
296+ <dt>Opérateur</dt><dd>${esc(site?.operator || n.provider || "")}</dd>
297+ ${isMac ? `<dt>Tunnel</dt><dd data-d="peer">—</dd>` : `<dt>Rôle tunnel</dt><dd>${n.tags.includes("gateway") ? "hub WireGuard + Caddy" : "hors tunnel"}</dd>`}
298+ <dt>Supervision</dt><dd>${isMac ? "maclustr-agentd · PM2" : "SSH direct"}</dd>
299+ </dl>
300+ </div>
301+ <div class="panel">
302+ <h3>Même emplacement</h3>
303+ <div style="display:flex;flex-wrap:wrap;gap:6px">${(nodesBySite[n.site] || []).filter(x => x.id !== n.id).map(x => `<a class="tag mono" href="/nodes/${x.id}" data-link>${esc(x.id)}</a>`).join("") || "<span class='muted'>Seul nœud ici.</span>"}</div>
304+ </div>
305+ </div>
306+ </div>
307+ <nav class="pager"><a href="/nodes/${prev.id}" data-link><small>← Précédent</small><b>${esc(prev.id)}</b></a><a class="next" href="/nodes/${next.id}" data-link><small>Suivant →</small><b>${esc(next.id)}</b></a></nav>
308+ </section>`;
309+
310+ const set = (k, v) => { const e = $(`[data-d="${k}"]`); if (e) e.textContent = v; };
311+ let hist = null;
312+ async function loadHist() {
313+ if (!isMac) return;
314+ try { const r = await fetch(`/api/history?node=${encodeURIComponent(n.id)}&window=${win}`); hist = await r.json(); } catch { hist = { points: [] }; }
315+ drawCharts();
316+ }
317+ function drawCharts() {
318+ if (!hist || !$("#chartCpu")) return;
319+ const pts = hist.points || [];
320+ chart($("#chartCpu"), pts, "cpu", { unit: "%", max: 100 });
321+ chart($("#chartMem"), pts, "mem", { unit: " Go", max: n.ram });
322+ chart($("#chartNet"), pts, "netIn", { unit: "", fmt: kbs });
323+ const last = pts[pts.length - 1];
324+ $("#cpuNow").textContent = last ? `${fmt1(last.cpu)} % · moy. ${fmt1(avg(pts, "cpu"))} %` : "";
325+ $("#memNow").textContent = last ? `${fmt1(last.mem)} Go · max ${fmt1(Math.max(...pts.map(p => p.mem || 0)))} Go` : "";
326+ $("#netNow").textContent = last ? `${kbs(last.netIn)} · max ${kbs(Math.max(...pts.map(p => p.netIn || 0)))}` : "";
327+ }
328+ $$("#winSeg button").forEach(b => b.addEventListener("click", () => { $$("#winSeg button").forEach(x => x.classList.remove("active")); b.classList.add("active"); win = b.dataset.w; loadHist(); }));
329+ const patch = () => {
330+ patchStatuses(view);
331+ $$("[data-appst]").forEach(el => { const [c, t] = appStatus(appById[el.dataset.appst]); el.className = `status ${c}`; el.textContent = t; });
175 if (!live) return; 332 if (!live) return;
176− if (!l) { st.className = "status na"; st.textContent = "inconnu"; return; } 333+ set("ts", new Date(live.ts).toLocaleTimeString("fr-CA"));
177− if (l.node) nd.textContent = l.node; 334+ if (isMac) {
178− const up = l.state === "up"; 335+ const l = liveNode(n.id);
179− st.className = `status ${up ? "on" : l.state === "degraded" ? "na" : "off"}`; 336+ patchMeters(view, n.id);
180− st.textContent = up ? "en ligne" : l.state === "degraded" ? "dégradée" : l.state === "down" ? "hors service" : (l.state || "—"); 337+ if (l?.online) {
181− const parts = []; 338+ set("os", l.os ? `${l.os}` : "—"); set("uptime", uptime(l.uptimeS)); set("load", `${l.load1}`); set("procs", fmt(l.procs || 0));
182− if (l.publicMs != null && l.publicOk) parts.push(`${l.publicMs} ms public`); 339+ set("net", `${kbs(l.netInKBs)} ↓ · ${kbs(l.netOutKBs)} ↑`); set("swap", `${fmt1(l.swapGB || 0)} Go`); set("thermal", l.thermal || "—");
183− if (l.uptime24h != null) parts.push(`${Math.round(l.uptime24h)} % / 24 h`); 340+ } else { ["os", "uptime", "load", "procs", "net", "swap", "thermal"].forEach(k => set(k, "—")); }
184− if (l.via) parts.push(`via ${l.via}`); 341+ const p = peerOf(n.id);
185− ms.textContent = parts.join(" · "); 342+ set("peer", p ? `${p.online ? "raccordé" : "handshake ancien"} · ${p.gw} · ${bytes(p.rx)} ↑` : "non raccordé");
186− }); 343+ } else {
344+ set("os", "Ubuntu 24.04"); const g = gwOf(n.id);
345+ if (g) {
346+ set("routes", g.routes ?? "—"); set("peers", `${g.peers.filter(p => p.online).length}/${g.peers.length}`); set("traffic", bytes(g.peers.reduce((s, p) => s + p.rx, 0)));
347+ $("#peerList").innerHTML = g.peers.sort((a, b) => a.alias.localeCompare(b.alias)).map(p => `<a class="tag mono ${p.online ? "accent" : ""}" href="/nodes/${p.alias}" data-link title="${p.online ? "handshake il y a " + p.handshakeS + " s" : "pas de handshake récent"}">${esc(p.alias)}</a>`).join("");
348+ set("uptime", "—");
349+ } else { set("routes", "—"); set("peers", "—"); set("traffic", "—"); set("uptime", "—"); if ($("#peerList")) $("#peerList").innerHTML = `<span class="muted" style="font-size:.86rem">${n.tags.includes("gateway") ? "état non remonté" : "Serveur de réserve : aucun service public pour l'instant."}</span>`; }
350+ }
351+ };
352+ patch(); loadHist();
353+ return { patch, destroy() {} };
354+ };
355+ const avg = (pts, k) => { const v = pts.map(p => p[k]).filter(x => x != null); return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 0; };
356+
357+ // graphe SVG : ligne 2 px, aire douce, 3 lignes de grille, min/max, crosshair + infobulle
358+ function chart(host, pts, key, opt) {
359+ const data = pts.filter(p => p[key] != null);
360+ if (data.length < 2) { host.innerHTML = `<div class="chart-empty">Pas encore d'historique pour cette fenêtre.</div>`; return; }
361+ const W = 600, H = 150, padL = 52, padR = 8, padT = 8, padB = 20;
362+ const xs = data.map(p => p.ts), ys = data.map(p => p[key]);
363+ const x0 = xs[0], x1 = xs[xs.length - 1]; const yMax = opt.max || Math.max(1, Math.max(...ys) * 1.15);
364+ const X = t => padL + (t - x0) / Math.max(1, x1 - x0) * (W - padL - padR);
365+ const Y = v => padT + (1 - Math.min(v, yMax) / yMax) * (H - padT - padB);
366+ const path = data.map((p, i) => `${i ? "L" : "M"}${X(p.ts).toFixed(1)},${Y(p[key]).toFixed(1)}`).join("");
367+ const area = `${path}L${X(x1).toFixed(1)},${(H - padB).toFixed(1)}L${X(x0).toFixed(1)},${(H - padB).toFixed(1)}Z`;
368+ const ticks = [0, 0.5, 1].map(f => yMax * f);
369+ const tLbl = t => new Date(t * 1000).toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" });
370+ const fmtV = opt.fmt ? opt.fmt : (v => (opt.unit === "%" ? Math.round(v) : fmt1(v)) + opt.unit);
371+ host.innerHTML = `<svg class="chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${key}">
372+ <defs><linearGradient id="areaGrad" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#60a5fa" stop-opacity=".28"/><stop offset="1" stop-color="#60a5fa" stop-opacity="0"/></linearGradient></defs>
373+ ${ticks.map(v => `<line class="grid-l" x1="${padL}" x2="${W - padR}" y1="${Y(v).toFixed(1)}" y2="${Y(v).toFixed(1)}"/><text class="axis" x="${padL - 6}" y="${(Y(v) + 3).toFixed(1)}" text-anchor="end">${fmtV(v)}</text>`).join("")}
374+ <text class="axis" x="${padL}" y="${H - 6}">${tLbl(x0)}</text><text class="axis" x="${W - padR}" y="${H - 6}" text-anchor="end">${tLbl(x1)}</text>
375+ <path class="area" d="${area}"/><path class="line" d="${path}"/>
376+ <line class="cross" y1="${padT}" y2="${H - padB}" x1="0" x2="0"/><circle class="pt" cx="0" cy="0"/>
377+ <rect x="${padL}" y="0" width="${W - padL - padR}" height="${H}" fill="transparent" class="hit"/></svg>`;
378+ const svg = host.querySelector("svg"), cross = svg.querySelector(".cross"), dot = svg.querySelector(".pt"), hit = svg.querySelector(".hit");
379+ const move = e => {
380+ const r = svg.getBoundingClientRect(); const cx = (e.touches ? e.touches[0].clientX : e.clientX);
381+ const fx = (cx - r.left) / r.width * W; const t = x0 + (fx - padL) / (W - padL - padR) * (x1 - x0);
382+ let best = data[0], bd = Infinity; for (const p of data) { const d = Math.abs(p.ts - t); if (d < bd) { bd = d; best = p; } }
383+ const px = X(best.ts), py = Y(best[key]);
384+ cross.setAttribute("x1", px); cross.setAttribute("x2", px); cross.style.opacity = 1; dot.setAttribute("cx", px); dot.setAttribute("cy", py); dot.style.opacity = 1;
385+ showTip(`<b>${fmtV(best[key])}</b><div class="row"><span>${new Date(best.ts * 1000).toLocaleString("fr-CA", { weekday: "short", hour: "2-digit", minute: "2-digit" })}</span></div>`, cx, r.top + py / H * r.height);
386+ };
387+ const leave = () => { cross.style.opacity = 0; dot.style.opacity = 0; hideTip(); };
388+ hit.addEventListener("mousemove", move); hit.addEventListener("mouseleave", leave);
389+ hit.addEventListener("touchstart", move, { passive: true }); hit.addEventListener("touchmove", move, { passive: true }); hit.addEventListener("touchend", leave);
187 } 390 }
188 391
189− // ---------- Tunnel ---------- 392+ // ===== Applications =====
190− function renderGateways() { 393+ pages.apps = () => {
191− const host = $("#gateways"); host.innerHTML = ""; 394+ view.innerHTML = `
192− D.tunnel.gateways.forEach(g => { 395+ <section class="page-head fade">
193− const n = D.nodes.find(x => x.id === g.id); 396+ <p class="eyebrow">Hébergement</p>
194− const c = el("div", `gw ${g.primary ? "primary" : ""}`); c.dataset.gw = g.id; 397+ <h1>${totals.apps} applications en production.</h1>
195− c.innerHTML = ` 398+ <p class="lede">Chaque application a un manifeste, un nœud courant, un processus supervisé et, pour les sites publics, une route HTTPS sur la passerelle. L'état est confirmé en direct par maclustr-agentd.</p>
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> 399+ </section>
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> 400+ <section class="section tight" id="appsRoot">
198− <div class="gw-stats"> 401+ ${D.appGroups.map(g => { const apps = D.apps.filter(a => a.group === g.id); return `
199− <div class="spec"><b data-routes>—</b><span>routes HTTPS</span></div> 402+ <div class="app-group" id="${g.id}">
200− <div class="spec"><b data-peers>—</b><span>pairs en ligne</span></div> 403+ <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>
201− <div class="spec"><b data-traffic>—</b><span>trafic sortant</span></div> 404+ <div class="grid c3">${apps.map(a => `
405+ <${a.domain ? `a href="https://${esc(a.domain)}/" target="_blank" rel="noopener"` : "div"} class="app" id="${a.id}" data-app="${a.id}">
406+ <div class="app-top"><span class="app-name">${esc(a.name)}</span><span class="status na" data-appst="${a.id}">…</span></div>
407+ <div class="app-domain ${a.domain ? "" : "none"}">${a.domain ? esc(a.domain) : "service interne"}</div>
408+ <p class="app-desc">${esc(a.desc)}</p>
409+ <div class="app-meta"><a class="tag mono" href="/nodes/${a.node}" data-link data-appnode="${a.id}">${esc(a.node)}</a><span class="tag mono">:${a.port}</span><span class="ms" data-ms="${a.id}"></span></div>
410+ </${a.domain ? "a" : "div"}>`).join("")}</div>
411+ </div>`; }).join("")}
412+ </section>`;
413+ const patch = () => {
414+ $$("[data-appst]").forEach(el => { const a = appById[el.dataset.appst]; const [c, t] = appStatus(a); el.className = `status ${c}`; el.textContent = t; const l = liveApp(a.id); const ms = $(`[data-ms="${a.id}"]`); const nd = $(`[data-appnode="${a.id}"]`); if (l) { if (l.node && nd) { nd.textContent = l.node; nd.href = `/nodes/${l.node}`; } const parts = []; if (l.publicOk && l.publicMs != null) parts.push(`${l.publicMs} ms`); if (l.uptime24h != null) parts.push(`${Math.round(l.uptime24h)} % / 24 h`); if (l.via) parts.push(`via ${l.via}`); if (ms) ms.textContent = parts.join(" · "); } });
415+ };
416+ patch();
417+ // ancre
418+ const h = location.hash.slice(1); if (h) requestAnimationFrame(() => { const el = document.getElementById(h); if (el) { el.scrollIntoView({ block: "start" }); window.scrollBy(0, -70); } });
419+ return { patch };
420+ };
421+
422+ // ===== Carte =====
423+ pages.map = (params) => {
424+ view.innerHTML = `
425+ <section class="page-head fade">
426+ <p class="eyebrow">Emplacements</p>
427+ <h1>${totals.places} sites, ${totals.countries} pays, un seul cluster.</h1>
428+ <p class="lede">Le LAN de Saint-Augustin porte le cœur du cluster ; les passerelles OVHcloud de Beauharnois et Gravelines publient les sites ; les Macs loués à Istanbul, Atlanta, Dublin et Limassol étendent la capacité. Tout est relié par WireGuard.</p>
429+ </section>
430+ <div class="map-layout">
431+ <div class="map-box" id="mapBox"><div class="map-fallback" id="mapFallback">Chargement de la carte…</div></div>
432+ <div>
433+ <div class="site-list" id="siteList">${Object.entries(D.sites).sort((a, b) => (nodesBySite[b[0]]?.length || 0) - (nodesBySite[a[0]]?.length || 0)).map(([k, s]) => { const ns = nodesBySite[k] || []; const cores = ns.reduce((a, n) => a + n.cores, 0), ram = ns.reduce((a, n) => a + n.ram, 0); return `<button class="card site-row" data-site="${k}"><div class="t"><span style="display:flex;align-items:center"><i class="k ${s.kind}"></i>${esc(s.name)}</span><span class="cnt">${ns.length} nœud${ns.length > 1 ? "s" : ""}</span></div><div class="r">${esc(s.region)} · ${esc(s.operator)}</div><div class="m">${fmt(cores)} ${s.kind === "ovh" ? "thr" : "cœurs"} · ${ramFmt(ram)} · <span data-siteon="${k}">…</span></div></button>`; }).join("")}</div>
434+ <div class="legend"><span><i></i>LAN MacLustr</span><span><i class="ovh"></i>OVHcloud</span><span><i class="rented"></i>Macs loués</span></div>
202 </div> 435 </div>
203− <div class="peers-title">Pairs WireGuard</div> 436+ </div>
204− <div class="peers" data-peerlist><span class="peer">chargement…</span></div>`; 437+ <section class="section tight">
205− host.appendChild(c); 438+ <div class="grid c2">
206− }); 439+ ${Object.entries(D.sites).map(([k, s]) => { const ns = nodesBySite[k] || []; return `<div class="card pad" id="site-${k}"><div style="display:flex;justify-content:space-between;gap:8px;align-items:baseline"><h3 style="font-size:1.05rem">${esc(s.name)} <span class="faint" style="font-weight:400;font-size:.88rem">· ${esc(s.region)}</span></h3><span class="mono faint" style="font-size:.78rem">${s.lat.toFixed(2)}, ${s.lon.toFixed(2)}</span></div><p class="muted" style="font-size:.9rem;margin-top:8px">${esc(s.note)}</p><div style="display:flex;flex-wrap:wrap;gap:6px;margin-top:12px">${ns.map(n => `<a class="tag mono" href="/nodes/${n.id}" data-link>${esc(n.id)}</a>`).join("")}</div></div>`; }).join("")}
207− patchGatewaysLive(); 440+ </div>
441+ </section>`;
442+
443+ let map = null, markers = {}, destroyed = false, popup = null;
444+ const siteOnline = k => { const ns = nodesBySite[k] || []; return ns.filter(n => n.group === "ovh" ? (gwOf(n.id)?.ok ?? true) : liveNode(n.id)?.online).length; };
445+ const popupHTML = (k) => { const s = D.sites[k]; const ns = nodesBySite[k] || []; return `<div style="font-weight:600;margin-bottom:2px">${esc(s.name)} <span style="color:#a1a1aa;font-weight:400">· ${esc(s.region)}</span></div><div style="color:#a1a1aa;font-size:.76rem;margin-bottom:8px">${esc(s.operator)}</div><div style="display:flex;flex-wrap:wrap;gap:4px">${ns.map(n => `<a href="/nodes/${n.id}" data-link style="font-family:var(--mono);font-size:.72rem;padding:2px 6px;border-radius:5px;border:1px solid ${(n.group === "ovh" || liveNode(n.id)?.online) ? "rgba(34,197,94,.45)" : "#2c2c35"};color:#f4f4f5">${esc(n.id)}</a>`).join("")}</div>`; };
446+ const focus = (k, fly = true) => {
447+ $$(".site-row").forEach(r => r.classList.toggle("active", r.dataset.site === k));
448+ if (!map || !D.sites[k]) return;
449+ const s = D.sites[k];
450+ if (fly) map.flyTo({ center: [s.lon, s.lat], zoom: Math.max(map.getZoom(), 4.2), speed: .9, essential: true });
451+ if (popup) popup.remove();
452+ popup = new maplibregl.Popup({ offset: 14, closeButton: true, maxWidth: "280px" }).setLngLat([s.lon, s.lat]).setHTML(popupHTML(k)).addTo(map);
453+ };
454+ $$(".site-row").forEach(r => r.addEventListener("click", () => { focus(r.dataset.site); $("#mapBox").scrollIntoView({ block: "nearest", behavior: "smooth" }); }));
455+
456+ loadMapLibre().then(() => {
457+ if (destroyed) return;
458+ const box = $("#mapBox"); $("#mapFallback")?.remove();
459+ const el = document.createElement("div"); el.style.position = "absolute"; el.style.inset = "0"; box.appendChild(el);
460+ map = new maplibregl.Map({ container: el, style: "https://tiles.openfreemap.org/styles/dark", center: window.innerWidth < 760 ? [-25, 40] : [-20, 44], zoom: window.innerWidth < 760 ? 1.3 : 1.9, minZoom: 1, attributionControl: { compact: true }, cooperativeGestures: window.innerWidth < 760 });
461+ map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
462+ map.on("style.load", () => {
463+ try { map.setProjection({ type: "globe" }); } catch { }
464+ // liens WireGuard : chaque site → passerelles (Beauharnois, Gravelines)
465+ const hubs = ["bhs", "gra"];
466+ const feats = [];
467+ Object.entries(D.sites).forEach(([k, s]) => { if (hubs.includes(k)) return; hubs.forEach(h => { const t = D.sites[h]; feats.push({ type: "Feature", properties: { primary: h === "bhs" }, geometry: { type: "LineString", coordinates: arc([s.lon, s.lat], [t.lon, t.lat]) } }); }); });
468+ feats.push({ type: "Feature", properties: { primary: false }, geometry: { type: "LineString", coordinates: arc([D.sites.bhs.lon, D.sites.bhs.lat], [D.sites.gra.lon, D.sites.gra.lat]) } });
469+ map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: feats } });
470+ map.addLayer({ id: "links", type: "line", source: "links", paint: { "line-color": "#60a5fa", "line-width": ["case", ["get", "primary"], 1.6, .9], "line-opacity": ["case", ["get", "primary"], .55, .3], "line-dasharray": [2, 2] } });
471+ });
472+ Object.entries(D.sites).forEach(([k, s]) => {
473+ const ns = nodesBySite[k] || [];
474+ const d = document.createElement("div"); d.className = `pin ${s.kind} ${ns.length >= 8 ? "big" : ""}`; d.innerHTML = `<span class="lbl">${esc(s.name)} · ${ns.length}</span>`;
475+ d.addEventListener("click", (e) => { e.stopPropagation(); focus(k, false); });
476+ markers[k] = new maplibregl.Marker({ element: d, anchor: "center" }).setLngLat([s.lon, s.lat]).addTo(map);
477+ });
478+ const zoomCls = () => { box.classList.toggle("z-far", map.getZoom() < 3); }; map.on("zoom", zoomCls); zoomCls();
479+ map.on("load", () => { if (params.site && D.sites[params.site]) setTimeout(() => focus(params.site), 400); });
480+ map.on("error", () => { });
481+ }).catch(() => { const f = $("#mapFallback"); if (f) f.textContent = "La carte interactive n'a pas pu se charger (réseau). La liste des emplacements ci-contre reste disponible."; });
482+
483+ const patch = () => { Object.keys(D.sites).forEach(k => { const e = $(`[data-siteon="${k}"]`); if (e) e.textContent = live ? `${siteOnline(k)}/${(nodesBySite[k] || []).length} en ligne` : "…"; }); };
484+ patch();
485+ return { patch, destroy() { destroyed = true; try { map?.remove(); } catch { } } };
486+ };
487+ function arc(a, b, n = 40) { // grand cercle approximatif
488+ const toR = d => d * Math.PI / 180, toD = r => r * 180 / Math.PI;
489+ const [lon1, lat1, lon2, lat2] = [toR(a[0]), toR(a[1]), toR(b[0]), toR(b[1])];
490+ const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lon2 - lon1) / 2) ** 2));
491+ if (!d) return [a, b];
492+ const out = [];
493+ for (let i = 0; i <= n; i++) { const f = i / n; const A = Math.sin((1 - f) * d) / Math.sin(d), B = Math.sin(f * d) / Math.sin(d); const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2); const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2); const z = A * Math.sin(lat1) + B * Math.sin(lat2); out.push([toD(Math.atan2(y, x)), toD(Math.atan2(z, Math.sqrt(x * x + y * y)))]); }
494+ return out;
208 } 495 }
209− const bytes = b => b >= 1e12 ? (b / 1e12).toFixed(1) + " To" : b >= 1e9 ? (b / 1e9).toFixed(1) + " Go" : (b / 1e6).toFixed(0) + " Mo"; 496+ let mapLibP = null;
210− function patchGatewaysLive() { 497+ function loadMapLibre() {
211− if (!live?.tunnel) return; 498+ if (window.maplibregl) return Promise.resolve();
212− document.querySelectorAll(".gw").forEach(c => { 499+ if (mapLibP) return mapLibP;
213− const g = live.tunnel.find(x => x.name === c.dataset.gw); if (!g) return; 500+ mapLibP = new Promise((res, rej) => {
214− const st = c.querySelector("[data-status]"); 501+ const css = document.createElement("link"); css.rel = "stylesheet"; css.href = "https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css"; document.head.appendChild(css);
215− st.className = `status ${g.ok ? "on" : "off"}`; st.textContent = g.ok ? "active" : "injoignable"; 502+ const s = document.createElement("script"); s.src = "https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"; s.onload = () => res(); s.onerror = () => { mapLibP = null; rej(new Error("maplibre")); }; document.head.appendChild(s);
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 }); 503 });
504+ return mapLibP;
222 } 505 }
223 506
224− // ---------- Live ---------- 507+ // ===== Contact =====
508+ pages.contact = () => {
509+ const c = D.contact;
510+ view.innerHTML = `
511+ <section class="page-head fade">
512+ <p class="eyebrow">Contact</p>
513+ <h1>Parlons du cluster.</h1>
514+ <p class="lede">Questions sur MacLustr, sur une application hébergée, sur une collaboration ou un accès API : une seule adresse.</p>
515+ </section>
516+ <div class="contact-grid">
517+ <div>
518+ <a class="mail" href="mailto:${esc(c.email)}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>${esc(c.email)}</a>
519+ <p class="copy"><button id="copyMail">Copier l'adresse</button> <span id="copied"></span></p>
520+ <div class="card pad" style="margin-top:22px">
521+ <h3 style="font-size:1rem;margin-bottom:10px">Ce que vous pouvez demander</h3>
522+ <ul class="prose" style="margin:0;padding-left:18px;display:grid;gap:6px">
523+ <li>Une clé ou une limite plus élevée pour une API du cluster (HF Market Data, PDB API, Fetcha…).</li>
524+ <li>L'hébergement d'un projet ou d'un service sur MacLustr.</li>
525+ <li>Du calcul distribué ponctuel sur les ${totals.cores} cœurs.</li>
526+ <li>Une question sur l'architecture, le tunnel ou l'orchestrateur.</li>
527+ </ul>
528+ </div>
529+ </div>
530+ <div style="display:flex;flex-direction:column;gap:10px">
531+ <div class="card pad"><h3 style="font-size:1rem;margin-bottom:10px">Responsable</h3><dl class="kv"><dt>Nom</dt><dd style="font-family:var(--sans)">${esc(c.owner)}</dd><dt>Courriel</dt><dd>${esc(c.email)}</dd><dt>Site</dt><dd><a href="${esc(c.site)}" rel="noopener">${esc(c.site.replace("https://", ""))}</a></dd><dt>Forge</dt><dd><a href="${esc(c.git)}" rel="noopener">${esc(c.git.replace("https://", ""))}</a></dd></dl></div>
532+ <div class="card pad"><h3 style="font-size:1rem;margin-bottom:10px">Où</h3><p class="prose">Saint-Augustin-de-Desmaures, Québec, Canada. Fuseau America/Toronto (UTC−4 / −5).</p><a class="btn small" style="margin-top:12px" href="/map?site=staug" data-link>Voir sur la carte</a></div>
533+ <div class="card pad"><h3 style="font-size:1rem;margin-bottom:10px">État du service</h3><p class="prose" id="contactLive">…</p></div>
534+ </div>
535+ </div>`;
536+ $("#copyMail").addEventListener("click", async () => { try { await navigator.clipboard.writeText(c.email); $("#copied").textContent = "copiée ✓"; setTimeout(() => $("#copied").textContent = "", 2000); } catch { } });
537+ const patch = () => { const e = $("#contactLive"); if (!e) return; e.textContent = live?.summary ? `${live.summary.nodesOnline}/${live.summary.nodesTotal} Macs en ligne, ${live.summary.apps?.up ?? "—"}/${live.summary.apps?.total ?? "—"} applications saines à ${new Date(live.ts).toLocaleTimeString("fr-CA")}.` : "État live indisponible pour le moment."; };
538+ patch();
539+ return { patch };
540+ };
541+
542+ pages.notFound = (msg) => {
543+ view.innerHTML = `<section class="page-head fade"><p class="eyebrow">404</p><h1>Page introuvable.</h1><p class="lede">${msg || "Cette adresse ne mène nulle part."}</p><div class="cta" style="margin-top:20px"><a class="btn primary" href="/" data-link>Accueil</a><a class="btn" href="/nodes" data-link>Nœuds</a></div></section>`;
544+ return { patch() { } };
545+ };
546+
547+ // ---------- routeur ----------
548+ function route() {
549+ const path = location.pathname.replace(/\/+$/, "") || "/";
550+ const params = Object.fromEntries(new URLSearchParams(location.search));
551+ page?.destroy?.(); hideTip();
552+ let name = "home", out;
553+ if (path === "/") out = pages.home();
554+ else if (path === "/nodes") { name = "nodes"; out = pages.nodes(); }
555+ else if (path.startsWith("/nodes/")) { name = "nodes"; out = pages.node(decodeURIComponent(path.slice(7))); }
556+ else if (path === "/apps") { name = "apps"; out = pages.apps(); }
557+ else if (path === "/map") { name = "map"; out = pages.map(params); }
558+ else if (path === "/contact") { name = "contact"; out = pages.contact(); }
559+ else { name = ""; out = pages.notFound(); }
560+ page = out;
561+ $$("[data-route]").forEach(a => a.classList.toggle("active", a.dataset.route === name));
562+ const titles = { home: "MacLustr — Le cluster Apple Silicon", nodes: path.startsWith("/nodes/") ? `${path.slice(7)} — nœud MacLustr` : "Nœuds — MacLustr", apps: "Applications — MacLustr", map: "Carte — MacLustr", contact: "Contact — MacLustr" };
563+ document.title = titles[name] || "MacLustr";
564+ $("#drawer").classList.remove("open"); $("#burger").setAttribute("aria-expanded", "false");
565+ }
566+ document.addEventListener("click", e => {
567+ const a = e.target.closest("a[data-link]"); if (!a || e.metaKey || e.ctrlKey || e.shiftKey || a.target === "_blank") return;
568+ const href = a.getAttribute("href"); if (!href || href.startsWith("http") || href.startsWith("mailto")) return;
569+ e.preventDefault();
570+ if (href.startsWith("#")) { const el = document.getElementById(href.slice(1)); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } return; }
571+ if (href === location.pathname + location.search + location.hash) { window.scrollTo({ top: 0, behavior: "smooth" }); return; }
572+ history.pushState(null, "", href); route(); window.scrollTo(0, 0);
573+ });
574+ window.addEventListener("popstate", route);
575+ $("#burger").addEventListener("click", () => { const d = $("#drawer"); const open = d.classList.toggle("open"); $("#burger").setAttribute("aria-expanded", String(open)); });
576+
577+ // ---------- live ----------
225 async function refresh() { 578 async function refresh() {
226 const pill = $("#livePill"); 579 const pill = $("#livePill");
227 try { 580 try {
228− const r = await fetch("/api/live", { cache: "no-store" }); 581+ const r = await fetch("/api/live", { cache: "no-store" }); const j = await r.json();
229− const j = await r.json();
230 if (!j.summary) throw new Error(j.error || "sans données"); 582 if (!j.summary) throw new Error(j.error || "sans données");
231− live = j; 583+ live = j; pill.className = "live-pill ok";
232− pill.className = "live-pill ok"; 584+ pill.querySelector(".txt").textContent = `${j.summary.nodesOnline}/${j.summary.nodesTotal} Macs · ${j.summary.apps ? j.summary.apps.up + "/" + j.summary.apps.total + " apps" : ""}`;
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" : ""}`; 585+ $("#footMeta").textContent = `Inventaire au ${new Date(D.updated + "T12:00:00").toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" })} · état live ${new Date(j.ts).toLocaleTimeString("fr-CA")}${j.stale ? " (cache)" : ""} · agentd ${j.agentVersion || ""}`;
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) { 586 } catch (e) {
238− pill.className = "live-pill bad"; 587+ pill.className = "live-pill bad"; pill.querySelector(".txt").textContent = "live indisponible";
239− pill.querySelector(".txt").textContent = "live indisponible"; 588+ if (!live) live = { ts: Date.now(), nodes: {}, apps: {}, tunnel: [], summary: null, dead: true };
240− if (!live) { live = { nodes: {}, apps: {}, tunnel: [], summary: null }; patchNodesLive(); live = null; }
241 } 589 }
590+ page?.patch?.();
242 } 591 }
243− 592+ route(); refresh(); setInterval(refresh, 30000);
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(); }); 593 document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
259 })(); 594 })();
modified public/data.js +50 −38
@@ -2,87 +2,99 @@
2 Les états live (en ligne, charge, RAM, santé des apps) viennent de /api/live. */ 2 Les états live (en ligne, charge, RAM, santé des apps) viennent de /api/live. */
3 window.MACLUSTR = { 3 window.MACLUSTR = {
4 updated: "2026-09-10", 4 updated: "2026-09-10",
5+ contact: { email: "contact@spboucher.ai", owner: "Simon-Pierre Boucher", site: "https://www.spboucher.ai", git: "https://git.spboucher.ai" },
6+
7+ // Emplacements physiques (lat/lon) — clé `site` sur chaque nœud
8+ sites: {
9+ staug: { name: "Saint-Augustin-de-Desmaures", region: "Québec, Canada", lat: 46.741, lon: -71.458, kind: "lan", operator: "MacLustr (LAN privé)", note: "Le cœur du cluster : 21 Macs sur un réseau local 10 GbE, deux NAS UGREEN." },
10+ bhs: { name: "Beauharnois", region: "Québec, Canada", lat: 45.313, lon: -73.873, kind: "ovh", operator: "OVHcloud BHS8", note: "Passerelle principale du MacLustr Tunnel et deux serveurs de réserve. ≈ 10 ms du LAN." },
11+ gra: { name: "Gravelines", region: "Hauts-de-France, France", lat: 50.987, lon: 2.128, kind: "ovh", operator: "OVHcloud GRA1", note: "Passerelle secondaire : secours et présence européenne." },
12+ ist: { name: "Istanbul", region: "Turquie", lat: 41.008, lon: 28.978, kind: "rented", operator: "Macly (réseau Erlion)", note: "Huit Macs dédiés loués : un Mac Studio M1 Max 64 Go et sept Mac mini." },
13+ atl: { name: "Atlanta", region: "Géorgie, États-Unis", lat: 33.786, lon: -84.406, kind: "rented", operator: "MacStadium", note: "Mac mini M2 Pro dédié." },
14+ dub: { name: "Dublin", region: "Irlande", lat: 53.335, lon: -6.252, kind: "rented", operator: "MacStadium", note: "Deux Mac mini dédiés (M1, M2)." },
15+ lim: { name: "Limassol", region: "Chypre", lat: 34.687, lon: 33.037, kind: "rented", operator: "rentamac", note: "Mac mini M4 derrière NAT, joint par la passerelle SSH du fournisseur." }
16+ },
5 totals: { macs: 33, ovh: 4, apps: 36, sites: 35 }, 17 totals: { macs: 33, ovh: 4, apps: 36, sites: 35 },
6 18
7 // group: lan | rented | ovh 19 // group: lan | rented | ovh
8 nodes: [ 20 nodes: [
9 // ---- LAN Saint-Augustin (192.168.2.x, maclustr.io) ---- 21 // ---- 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", 22+ { id:"M3U96a", group:"lan", site:"staug", 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).", 23 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"] }, 24 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", 25+ { id:"M3U96b", group:"lan", site:"staug", 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.", 26 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é"] }, 27 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", 28+ { id:"M2U64", group:"lan", site:"staug", 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.", 29 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"] }, 30 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", 31+ { id:"M4M64a", group:"lan", site:"staug", 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, ce site www.maclustr.io, ainsi que Lou·Ka, Fabri·Ka, Ka·Stats, UQO-Chat et PDB API.", 32 role:"Applications + agent de supervision", desc:"Héberge maclustr-agentd (:9210), la source des apps macOS et iOS MacLustr, ce site www.maclustr.io, ainsi que Lou·Ka, Fabri·Ka, Ka·Stats, UQO-Chat et PDB API.",
21 tags:["worker","apps","monitoring"] }, 33 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", 34+ { id:"M4M64b", group:"lan", site:"staug", 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.", 35 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"] }, 36 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", 37+ { id:"M4BP48", group:"lan", site:"staug", 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"] }, 38 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", 39+ { id:"M4BP36", group:"lan", site:"staug", 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"] }, 40 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", 41+ { id:"M4M36", group:"lan", site:"staug", 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"] }, 42 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", 43+ { id:"M2M32", group:"lan", site:"staug", 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"] }, 44 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", 45+ { id:"M2M32b", group:"lan", site:"staug", 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"] }, 46 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", 47+ { id:"M2M32c", group:"lan", site:"staug", 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"] }, 48 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", 49+ { id:"m4mc", group:"lan", site:"staug", 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"] }, 50 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", 51+ { id:"M1M32", group:"lan", site:"staug", 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.", 52 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"] }, 53 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"] }, 54+ { id:"m4ma", group:"lan", site:"staug", 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"] }, 55+ { id:"m4mb", group:"lan", site:"staug", 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"] }, 56+ { id:"m4md", group:"lan", site:"staug", 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"] }, 57+ { id:"m2m16", group:"lan", site:"staug", 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"] }, 58+ { id:"M3BA24", group:"lan", site:"staug", 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"] }, 59+ { id:"M3BA16", group:"lan", site:"staug", 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"] }, 60+ { id:"m2m8a", group:"lan", site:"staug", 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"] }, 61+ { id:"m2m8b", group:"lan", site:"staug", 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 62
51 // ---- Macs dédiés loués, hors LAN ---- 63 // ---- 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", 64+ { id:"M1M64", group:"rented", model:"Mac Studio", chip:"M1 Max", tier:"max", cores:10, gpu:32, ram:64, disk:1800, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
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é"] }, 65 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", 66+ { id:"m2m16b", group:"rented", model:"Mac mini", chip:"M2 Pro", tier:"pro", cores:10, gpu:16, ram:16, disk:926, site:"atl", provider:"MacStadium", location:"Atlanta, États-Unis",
55 role:"Nœud distant", desc:"Mac mini M2 Pro dédié, raccordé au tunnel MacLustr.", tags:["distant","réservé"] }, 67 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", 68+ { id:"m1m16", group:"rented", model:"Mac mini", chip:"M1", tier:"base", cores:8, gpu:8, ram:16, disk:926, site:"dub", provider:"MacStadium", location:"Dublin, Irlande",
57 role:"Nœud distant", desc:"Mac mini M1 dédié.", tags:["distant","réservé"] }, 69 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", 70+ { id:"m2m16c", group:"rented", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:16, disk:926, site:"dub", provider:"MacStadium", location:"Dublin, Irlande",
59 role:"Nœud distant", desc:"Mac mini M2 dédié.", tags:["distant","réservé"] }, 71 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", 72+ { id:"m4me", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
61 role:"Nœud distant", desc:"Mac mini M4 dédié, amorcé à distance (DeskIn + script), PM2 en LaunchDaemon.", tags:["distant","réservé"] }, 73 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", 74+ { id:"m4mf", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
63 role:"Nœud distant", desc:"Jumeau de m4me.", tags:["distant","réservé"] }, 75 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", 76+ { id:"m4mg", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"lim", 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"] }, 77+ role:"Nœud distant (Méditerranée)", 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", 78+ { id:"m4mh", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
67 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, 79 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", 80+ { id:"m1m16b", group:"rented", model:"Mac mini", chip:"M1", tier:"base", cores:8, gpu:8, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
69 role:"Nœud distant", desc:"Mac mini M1 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, 81 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", 82+ { id:"m4mi", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
71 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, 83 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", 84+ { id:"m4mj", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
73 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, 85 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", 86+ { id:"m4mk", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, site:"ist", provider:"Macly", location:"Istanbul, Turquie",
75 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] }, 87 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] },
76 88
77 // ---- Serveurs dédiés OVHcloud (Linux) ---- 89 // ---- 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", 90+ { id:"BHS64", group:"ovh", model:"OVH ADVANCE-2", chip:"AMD EPYC 4345P", tier:"epyc", cores:16, threads:true, gpu:0, ram:64, disk:960, site:"bhs", 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.", 91 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"] }, 92 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", 93+ { id:"BHS64b", group:"ovh", model:"OVH ADVANCE-2", chip:"AMD EPYC 4345P", tier:"epyc", cores:16, threads:true, gpu:0, ram:64, disk:960, site:"bhs", 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"] }, 94 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", 95+ { id:"BHS128", group:"ovh", model:"OVH ADVANCE-1", chip:"AMD EPYC 4245P", tier:"epyc", cores:12, threads:true, gpu:0, ram:128, disk:960, site:"bhs", 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"] }, 96 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", 97+ { 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, site:"gra", 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.", 98 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"] } 99 tags:["gateway","wireguard","caddy","linux","europe"] }
88 ], 100 ],
modified public/index.html +35 −162
@@ -3,181 +3,54 @@
3 <head> 3 <head>
4 <meta charset="utf-8"> 4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> 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> 6+<title>MacLustr — Le cluster Apple Silicon</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 36 applications en production, exposées au monde par le MacLustr Tunnel."> 7+<meta name="description" content="MacLustr : 33 Macs Apple Silicon et 4 serveurs OVHcloud répartis dans 7 emplacements, 484 cœurs, 1,4 To de RAM, 36 applications en production. Inventaire, état live, carte mondiale.">
8−<meta name="theme-color" content="#070a12"> 8+<meta name="theme-color" content="#09090b">
9 <meta property="og:title" content="MacLustr — Le cluster Apple Silicon"> 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, 36 applications en production."> 10+<meta property="og:description" content="33 Macs, 4 serveurs OVH, 7 emplacements sur 3 continents, 36 applications en production.">
11 <meta property="og:type" content="website"> 11 <meta property="og:type" content="website">
12 <meta property="og:url" content="https://www.maclustr.io/"> 12 <meta property="og:url" content="https://www.maclustr.io/">
13 <link rel="icon" href="/favicon.svg" type="image/svg+xml"> 13 <link rel="icon" href="/favicon.svg" type="image/svg+xml">
14 <link rel="stylesheet" href="/styles.css"> 14 <link rel="stylesheet" href="/styles.css">
15 </head> 15 </head>
16 <body> 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> 17+<a class="skip" href="#view">Aller au contenu</a>
18−
19 <header class="top"> 18 <header class="top">
20− <a class="brand" href="#top" aria-label="MacLustr"> 19+ <div class="top-in">
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> 20+ <a class="brand" href="/" data-link aria-label="MacLustr, accueil">
22− <span>MacLustr</span> 21+ <svg class="logo" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="3" width="11" height="11" rx="2.5" fill="currentColor"/><rect x="18" y="3" width="11" height="11" rx="2.5" fill="currentColor" opacity=".55"/><rect x="3" y="18" width="11" height="11" rx="2.5" fill="currentColor" opacity=".55"/><rect x="18" y="18" width="11" height="11" rx="2.5" fill="currentColor" opacity=".3"/></svg>
23− </a> 22+ <span>MacLustr</span>
24− <nav class="nav" aria-label="Sections"> 23+ </a>
25− <a href="#architecture">Architecture</a> 24+ <nav class="nav" id="nav" aria-label="Pages">
26− <a href="#noeuds">Nœuds</a> 25+ <a href="/" data-link data-route="home">Accueil</a>
27− <a href="#apps">Applications</a> 26+ <a href="/nodes" data-link data-route="nodes">Nœuds</a>
28− <a href="#tunnel">Tunnel</a> 27+ <a href="/apps" data-link data-route="apps">Applications</a>
29− <a href="#pile">Pile</a> 28+ <a href="/map" data-link data-route="map">Carte</a>
29+ <a href="/contact" data-link data-route="contact">Contact</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+ <button class="burger" id="burger" aria-label="Menu" aria-expanded="false"><span></span><span></span></button>
33+ </div>
34+ <nav class="drawer" id="drawer" aria-label="Pages (mobile)">
35+ <a href="/" data-link data-route="home">Accueil</a>
36+ <a href="/nodes" data-link data-route="nodes">Nœuds</a>
37+ <a href="/apps" data-link data-route="apps">Applications</a>
38+ <a href="/map" data-link data-route="map">Carte</a>
39+ <a href="/contact" data-link data-route="contact">Contact</a>
30 </nav> 40 </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> 41 </header>
33 42
34−<main id="top"> 43+<main id="view" class="view" tabindex="-1"></main>
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>36 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 36 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">36 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 44
172 <footer class="foot"> 45 <footer class="foot">
173− <div> 46+ <div class="foot-in">
174− <strong>MacLustr</strong> · cluster privé de Simon-Pierre Boucher · Saint-Augustin-de-Desmaures, Québec 47+ <div class="foot-brand">
175− </div> 48+ <div class="brand"><svg class="logo" viewBox="0 0 32 32" aria-hidden="true"><rect x="3" y="3" width="11" height="11" rx="2.5" fill="currentColor"/><rect x="18" y="3" width="11" height="11" rx="2.5" fill="currentColor" opacity=".55"/><rect x="3" y="18" width="11" height="11" rx="2.5" fill="currentColor" opacity=".55"/><rect x="18" y="18" width="11" height="11" rx="2.5" fill="currentColor" opacity=".3"/></svg><span>MacLustr</span></div>
176− <div class="foot-links"> 49+ <p>Cluster privé Apple Silicon de Simon-Pierre Boucher. Saint-Augustin-de-Desmaures, Québec.</p>
177− <a href="https://www.spboucher.ai" rel="noopener">spboucher.ai</a> 50+ </div>
178− <a href="https://git.spboucher.ai" rel="noopener">spbgit</a> 51+ <div class="foot-col"><h4>Explorer</h4><a href="/nodes" data-link>Nœuds</a><a href="/apps" data-link>Applications</a><a href="/map" data-link>Carte</a><a href="/contact" data-link>Contact</a></div>
179− <a href="https://www.groupe-ka.com" rel="noopener">Groupe Ka</a> 52+ <div class="foot-col"><h4>Ailleurs</h4><a href="https://www.spboucher.ai" rel="noopener">spboucher.ai</a><a href="https://git.spboucher.ai" rel="noopener">spbgit</a><a href="https://www.groupe-ka.com" rel="noopener">Groupe Ka</a><a href="https://www.hfmarketdata.io" rel="noopener">HF Market Data</a></div>
180− <a href="https://www.hfmarketdata.io" rel="noopener">HF Market Data</a> 53+ <div class="foot-col"><h4>Contact</h4><a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></div>
181 </div> 54 </div>
182 <div class="foot-meta" id="footMeta">Inventaire au 10 septembre 2026.</div> 55 <div class="foot-meta" id="footMeta">Inventaire au 10 septembre 2026.</div>
183 </footer> 56 </footer>
modified public/styles.css +308 −217
@@ -1,226 +1,317 @@
1−/* MacLustr — www.maclustr.io — mobile-first */ 1+/* MacLustr v2 — épuré, mobile-first */
2 :root { 2 :root {
3− --bg: #070a12; 3+ --bg: #09090b; --bg-2: #0f0f12; --surface: #111114; --surface-2: #17171b; --surface-3: #1e1e24;
4− --bg-2: #0b1020; 4+ --line: #1f1f26; --line-2: #2c2c35;
5− --surface: rgba(255,255,255,.045); 5+ --ink: #f4f4f5; --ink-2: #a1a1aa; --ink-3: #6b6b76;
6− --surface-2: rgba(255,255,255,.075); 6+ --accent: #60a5fa; --accent-2: #93c5fd; --accent-dim: rgba(96,165,250,.14);
7− --line: rgba(255,255,255,.10); 7+ --good: #22c55e; --warn: #f59e0b; --bad: #ef4444; --off: #52525b;
8− --line-2: rgba(255,255,255,.18); 8+ --r: 12px; --r-s: 8px;
9− --ink: #eef2ff; 9+ --pad: clamp(16px, 4vw, 40px); --maxw: 1200px;
10− --ink-2: #aab3cc; 10+ --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", Roboto, sans-serif;
11− --ink-3: #6f7a99; 11+ --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
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; 12 color-scheme: dark;
26 } 13 }
27 * { box-sizing: border-box; } 14 * { box-sizing: border-box; }
28−html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } 15+html { -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; } 16+body { margin: 0; font-family: var(--sans); background: var(--bg); color: var(--ink); line-height: 1.55; -webkit-font-smoothing: antialiased; overflow-x: hidden; }
17+body::before { content: ""; position: fixed; inset: 0; z-index: -1; background: radial-gradient(900px 420px at 50% -10%, rgba(96,165,250,.10), transparent 70%); pointer-events: none; }
30 a { color: inherit; text-decoration: none; } 18 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; } 19+h1, h2, h3, h4 { margin: 0; line-height: 1.1; letter-spacing: -.025em; font-weight: 650; }
32−h1, h2, h3, h4 { margin: 0; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; }
33 p { margin: 0; } 20 p { margin: 0; }
34−.grad { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; } 21+code, .mono { font-family: var(--mono); font-size: .92em; }
35−.eyebrow { font-size: .78rem; text-transform: uppercase; letter-spacing: .14em; color: var(--teal); font-weight: 600; margin-bottom: 12px; } 22+code { background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: .06em .4em; color: var(--accent-2); }
36− 23+.num { font-variant-numeric: tabular-nums; }
37−/* background */ 24+.muted { color: var(--ink-2); } .faint { color: var(--ink-3); }
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); } 25+.skip { position: absolute; left: -999px; top: 8px; background: var(--ink); color: var(--bg); padding: 8px 12px; border-radius: 8px; z-index: 100; }
39−.orb { position: absolute; border-radius: 50%; filter: blur(80px); opacity: .28; animation: drift 24s ease-in-out infinite alternate; } 26+.skip:focus { left: 8px; }
40−.o1 { width: 520px; height: 520px; left: -160px; top: -120px; background: var(--teal); } 27+:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
41−.o2 { width: 620px; height: 620px; right: -200px; top: 20vh; background: var(--indigo); animation-delay: -8s; } 28+
42−.o3 { width: 460px; height: 460px; left: 30vw; top: 120vh; background: var(--violet); animation-delay: -16s; } 29+/* ---------- header ---------- */
43−@keyframes drift { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(60px, 40px, 0) scale(1.08); } } 30+.top { position: sticky; top: 0; z-index: 20; background: rgba(9,9,11,.78); backdrop-filter: blur(16px) saturate(140%); -webkit-backdrop-filter: blur(16px) saturate(140%); border-bottom: 1px solid var(--line); }
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%); } 31+.top-in { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); height: 56px; display: flex; align-items: center; gap: 12px; padding-top: env(safe-area-inset-top); }
45−@media (prefers-reduced-motion: reduce) { .orb { animation: none; } html { scroll-behavior: auto; } } 32+.brand { display: inline-flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: -.02em; font-size: 1.05rem; color: var(--ink); }
46− 33+.logo { width: 22px; height: 22px; color: var(--accent); }
47−/* header */ 34+.nav { display: none; gap: 2px; margin-left: 18px; }
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); } 35+.nav a, .drawer a { padding: 7px 12px; border-radius: 8px; color: var(--ink-2); font-size: .92rem; font-weight: 500; }
49−.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 1.12rem; } 36+.nav a:hover { color: var(--ink); background: var(--surface-2); }
50−.logo { width: 26px; height: 26px; } 37+.nav a.active, .drawer a.active { color: var(--ink); background: var(--surface-3); }
51−.nav { display: none; gap: 4px; margin-left: 10px; } 38+.live-pill { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; padding: 5px 11px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .76rem; color: var(--ink-2); white-space: nowrap; font-family: var(--mono); }
52−.nav a { padding: 7px 12px; border-radius: 999px; color: var(--ink-2); font-size: .92rem; font-weight: 500; } 39+.live-pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--off); }
53−.nav a:hover { background: var(--surface-2); color: var(--ink); } 40+.live-pill.ok .dot { background: var(--good); box-shadow: 0 0 0 3px rgba(34,197,94,.18); }
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); } 41 .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); } } 42+.burger { display: inline-flex; flex-direction: column; justify-content: center; gap: 5px; width: 38px; height: 38px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); cursor: pointer; padding: 0 10px; }
59−@media (min-width: 900px) { .nav { display: flex; } } 43+.burger span { display: block; height: 2px; background: var(--ink); border-radius: 2px; transition: transform .2s; }
60− 44+.burger[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
61−/* layout */ 45+.burger[aria-expanded="true"] span:last-child { transform: translateY(-3.5px) rotate(-45deg); }
62−main { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); } 46+.drawer { display: none; flex-direction: column; padding: 8px var(--pad) 14px; border-top: 1px solid var(--line); background: var(--bg); }
63−.section { padding: clamp(56px, 9vw, 110px) 0 0; } 47+.drawer.open { display: flex; }
64−.sec-head { max-width: 760px; margin-bottom: clamp(24px, 4vw, 40px); } 48+.drawer a { padding: 12px 10px; font-size: 1rem; }
65−.sec-head h2 { font-size: clamp(1.7rem, 4.6vw, 2.7rem); } 49+@media (min-width: 860px) { .nav { display: flex; } .burger, .drawer { display: none !important; } .live-pill { font-size: .78rem; } }
66−.sec-head .sub { margin-top: 14px; color: var(--ink-2); font-size: clamp(.98rem, 1.6vw, 1.1rem); } 50+@media (max-width: 420px) { .live-pill .txt { max-width: 150px; overflow: hidden; text-overflow: ellipsis; } }
67− 51+
68−/* hero */ 52+/* ---------- layout ---------- */
69−.hero { padding: clamp(48px, 10vw, 120px) 0 0; } 53+.view { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); min-height: 60vh; }
70−.hero h1 { font-size: clamp(2.5rem, 8.5vw, 5.4rem); letter-spacing: -.035em; font-weight: 800; } 54+.section { padding: clamp(44px, 7vw, 88px) 0 0; }
71−.lede { margin-top: 22px; max-width: 720px; color: var(--ink-2); font-size: clamp(1.02rem, 1.9vw, 1.22rem); } 55+.section.tight { padding-top: clamp(28px, 4vw, 48px); }
72−.lede strong { color: var(--ink); font-weight: 600; } 56+.sec-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 10px 24px; margin-bottom: clamp(18px, 3vw, 28px); }
73−.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; } 57+.sec-head h2 { font-size: clamp(1.45rem, 3.4vw, 2rem); }
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; } 58+.sec-head .sub { color: var(--ink-2); max-width: 680px; margin-top: 8px; font-size: .98rem; }
75−.btn:active { transform: scale(.97); } 59+.sec-head .more { color: var(--accent-2); font-size: .92rem; font-weight: 500; white-space: nowrap; }
76−.btn.primary { background: var(--grad); color: #06101a; border-color: transparent; } 60+.eyebrow { font-size: .74rem; text-transform: uppercase; letter-spacing: .12em; color: var(--ink-3); font-weight: 600; margin-bottom: 10px; }
77−.btn.ghost { background: var(--surface); } 61+.page-head { padding: clamp(36px, 6vw, 72px) 0 0; }
78−.btn.ghost:hover { background: var(--surface-2); } 62+.page-head h1 { font-size: clamp(2rem, 5.6vw, 3.4rem); letter-spacing: -.035em; }
79− 63+.page-head .lede { color: var(--ink-2); max-width: 720px; margin-top: 14px; font-size: clamp(1rem, 1.6vw, 1.12rem); }
80−.kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: clamp(36px, 6vw, 64px); } 64+.crumbs { display: flex; gap: 8px; align-items: center; font-size: .84rem; color: var(--ink-3); margin-bottom: 14px; flex-wrap: wrap; }
81−.kpi { padding: 16px 16px 14px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; } 65+.crumbs a { color: var(--ink-2); } .crumbs a:hover { color: var(--ink); }
82−.kpi::after { content: ""; position: absolute; inset: auto 0 0 0; height: 2px; background: var(--grad); opacity: .7; } 66+
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; } 67+.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 11px 18px; border-radius: 10px; font-weight: 600; font-size: .94rem; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); transition: background .15s, border-color .15s, transform .1s; cursor: pointer; font-family: inherit; }
84−.kpi-l { color: var(--ink-2); font-size: .9rem; margin-top: 2px; } 68+.btn:hover { background: var(--surface-2); border-color: #3a3a46; }
85−.kpi-s { color: var(--ink-3); font-size: .74rem; margin-top: 6px; } 69+.btn:active { transform: scale(.98); }
86−@media (min-width: 640px) { .kpis { grid-template-columns: repeat(3, 1fr); } } 70+.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
87−@media (min-width: 1000px) { .kpis { grid-template-columns: repeat(6, 1fr); } } 71+.btn.primary:hover { background: #fff; }
88− 72+.btn.small { padding: 7px 12px; font-size: .84rem; border-radius: 8px; }
89−/* flow */ 73+
90−.flow { display: grid; grid-template-columns: 1fr; gap: 6px; } 74+.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); }
91−.stage { padding: 20px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); } 75+.card.pad { padding: 18px; }
92−.stage.accent { border-color: rgba(129,140,248,.45); background: linear-gradient(180deg, rgba(129,140,248,.10), rgba(45,212,191,.05)); } 76+.grid { display: grid; gap: 10px; grid-template-columns: 1fr; }
93−.stage-ico { font-size: 1.5rem; margin-bottom: 10px; } 77+@media (min-width: 640px) { .grid.c2, .grid.c3, .grid.c4 { grid-template-columns: repeat(2, 1fr); } }
94−.stage h3 { font-size: 1.08rem; margin-bottom: 8px; } 78+@media (min-width: 980px) { .grid.c3 { grid-template-columns: repeat(3, 1fr); } .grid.c4 { grid-template-columns: repeat(4, 1fr); } }
95−.stage p { color: var(--ink-2); font-size: .93rem; } 79+
96−.arrow { width: 2px; height: 22px; margin: 0 auto; background: linear-gradient(180deg, var(--teal), var(--indigo)); position: relative; opacity: .8; } 80+/* status */
97−.arrow::after { content: ""; position: absolute; bottom: -1px; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top: 8px solid var(--indigo); } 81+.status { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600; border: 1px solid var(--line-2); color: var(--ink-2); white-space: nowrap; background: var(--surface-2); }
98−@media (min-width: 1000px) { 82+.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--off); flex: none; }
99− .flow { grid-template-columns: 1fr 26px 1fr 26px 1fr 26px 1fr 26px 1fr; align-items: stretch; } 83+.status.on { color: #86efac; border-color: rgba(34,197,94,.35); background: rgba(34,197,94,.08); } .status.on::before { background: var(--good); }
100− .arrow { width: 26px; height: 2px; margin: auto 0; align-self: center; background: linear-gradient(90deg, var(--teal), var(--indigo)); } 84+.status.warn { color: #fcd34d; border-color: rgba(245,158,11,.35); } .status.warn::before { background: var(--warn); }
101− .arrow::after { bottom: auto; left: auto; right: -2px; top: 50%; transform: translateY(-50%); border: 6px solid transparent; border-left: 8px solid var(--indigo); } 85+.status.off { color: var(--ink-3); } .status.off::before { background: var(--off); }
102−} 86+.status.bad { color: #fca5a5; border-color: rgba(239,68,68,.35); } .status.bad::before { background: var(--bad); }
103−.pillars { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 28px; } 87+.status.na { border-style: dashed; }
104−.pillar { padding: 18px 20px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } 88+.tag { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: 6px; font-size: .74rem; font-weight: 500; color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); white-space: nowrap; }
105−.pillar .pi { font-size: 1.3rem; } 89+.tag.accent { color: var(--accent-2); background: var(--accent-dim); border-color: rgba(96,165,250,.3); }
106−.pillar h4 { font-size: 1rem; margin: 8px 0 6px; } 90+.tag.mono { font-family: var(--mono); }
107−.pillar p { color: var(--ink-2); font-size: .92rem; } 91+
108−@media (min-width: 720px) { .pillars { grid-template-columns: repeat(2, 1fr); } } 92+/* meters */
109−@media (min-width: 1100px) { .pillars { grid-template-columns: repeat(4, 1fr); } } 93+.meter { display: grid; grid-template-columns: 52px 1fr auto; align-items: center; gap: 10px; font-size: .8rem; color: var(--ink-2); }
110− 94+.meter .bar { height: 5px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
111−/* toolbar */ 95+.meter .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .6s ease; }
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); } 96+.meter .bar i.hi { background: var(--warn); } .meter .bar i.crit { background: var(--bad); }
113−.chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 2px; } 97+.meter .v { font-family: var(--mono); color: var(--ink); min-width: 5ch; text-align: right; font-size: .78rem; }
98+.meter.na .v { color: var(--ink-3); }
99+
100+/* ---------- home ---------- */
101+.hero { padding: clamp(56px, 10vw, 128px) 0 clamp(28px, 5vw, 56px); text-align: left; }
102+.hero h1 { font-size: clamp(2.6rem, 8vw, 5.6rem); letter-spacing: -.04em; line-height: .98; max-width: 14ch; }
103+.hero h1 .dim { color: var(--ink-3); }
104+.hero .lede { color: var(--ink-2); max-width: 640px; margin-top: 22px; font-size: clamp(1.02rem, 1.8vw, 1.2rem); }
105+.hero .lede strong { color: var(--ink); font-weight: 600; }
106+.hero .cta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
107+.facts { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--surface); }
108+.fact { padding: 18px 16px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
109+.fact b { display: block; font-size: clamp(1.6rem, 4vw, 2.2rem); font-weight: 650; letter-spacing: -.03em; font-family: var(--mono); line-height: 1.1; }
110+.fact span { display: block; color: var(--ink-2); font-size: .84rem; margin-top: 4px; }
111+.fact small { display: block; color: var(--ink-3); font-size: .74rem; margin-top: 6px; font-family: var(--mono); min-height: 1.1em; }
112+@media (min-width: 640px) { .facts { grid-template-columns: repeat(3, 1fr); } }
113+@media (min-width: 980px) { .facts { grid-template-columns: repeat(6, 1fr); } .fact { border-bottom: 0; } }
114+.facts .fact:last-child { border-right: 0; }
115+
116+.livegrid { display: grid; gap: 10px; grid-template-columns: 1fr; }
117+@media (min-width: 980px) { .livegrid { grid-template-columns: 1.15fr .85fr; } }
118+.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); padding: 18px; }
119+.panel h3 { font-size: .95rem; font-weight: 600; color: var(--ink-2); display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
120+.panel h3 .mono { color: var(--ink-3); font-size: .74rem; font-weight: 500; }
121+.bigstat { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px 10px; }
122+@media (min-width: 520px) { .bigstat { grid-template-columns: repeat(3, 1fr); } }
123+.bigstat div b { display: block; font-family: var(--mono); font-size: clamp(1.25rem, 3vw, 1.7rem); white-space: nowrap; letter-spacing: -.02em; line-height: 1.1; }
124+.bigstat div span { display: block; color: var(--ink-3); font-size: .76rem; margin-top: 3px; }
125+.rambar { margin-top: 16px; }
126+.rambar .lbl { display: flex; justify-content: space-between; font-size: .78rem; color: var(--ink-2); margin-bottom: 6px; }
127+.rambar .lbl b { color: var(--ink); font-family: var(--mono); font-weight: 500; }
128+.rambar .bar { height: 8px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
129+.rambar .bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width .6s; }
130+.heat { display: grid; grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 6px; }
131+.cell { position: relative; aspect-ratio: 1; border-radius: 8px; background: var(--surface-3); border: 1px solid var(--line); display: flex; align-items: flex-end; padding: 6px; overflow: hidden; transition: transform .12s, border-color .12s; }
132+.cell:hover { transform: translateY(-2px); border-color: var(--line-2); }
133+.cell i { position: absolute; inset: 0; background: var(--accent); opacity: 0; transition: opacity .6s; }
134+.cell.off { background: repeating-linear-gradient(135deg, var(--surface-2) 0 4px, var(--surface) 4px 8px); }
135+.cell span { position: relative; font-family: var(--mono); font-size: .62rem; color: var(--ink); line-height: 1; letter-spacing: -.02em; }
136+.cell.off span { color: var(--ink-3); }
137+.heat-legend { display: flex; align-items: center; gap: 8px; margin-top: 12px; font-size: .74rem; color: var(--ink-3); }
138+.heat-legend i { width: 60px; height: 6px; border-radius: 3px; background: linear-gradient(90deg, var(--surface-3), var(--accent)); }
139+.tip { position: fixed; z-index: 50; pointer-events: none; background: var(--surface-3); border: 1px solid var(--line-2); border-radius: 8px; padding: 8px 10px; font-size: .78rem; color: var(--ink); box-shadow: 0 8px 24px rgba(0,0,0,.4); opacity: 0; transition: opacity .1s; max-width: 240px; }
140+.tip.show { opacity: 1; }
141+.tip b { font-family: var(--mono); }
142+.tip .row { display: flex; justify-content: space-between; gap: 14px; color: var(--ink-2); } .tip .row span:last-child { color: var(--ink); font-family: var(--mono); }
143+
144+.steps { display: grid; grid-template-columns: 1fr; gap: 0; border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--surface); }
145+.step { padding: 20px 18px; border-bottom: 1px solid var(--line); position: relative; }
146+.step:last-child { border-bottom: 0; }
147+.step .n { font-family: var(--mono); color: var(--accent); font-size: .78rem; margin-bottom: 10px; }
148+.step h3 { font-size: 1rem; margin-bottom: 6px; }
149+.step p { color: var(--ink-2); font-size: .9rem; }
150+@media (min-width: 980px) { .steps { grid-template-columns: repeat(5, 1fr); } .step { border-bottom: 0; border-right: 1px solid var(--line); } .step:last-child { border-right: 0; } }
151+
152+.sites-strip { display: grid; grid-template-columns: 1fr; gap: 10px; }
153+@media (min-width: 640px) { .sites-strip { grid-template-columns: repeat(2, 1fr); } }
154+@media (min-width: 980px) { .sites-strip { grid-template-columns: repeat(4, 1fr); } }
155+.site-card { padding: 16px; display: flex; flex-direction: column; gap: 6px; transition: border-color .15s; }
156+.site-card:hover { border-color: var(--line-2); }
157+.site-card .where { font-weight: 600; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
158+.site-card .where .cnt { font-family: var(--mono); font-size: .8rem; color: var(--ink-2); }
159+.site-card .op { font-size: .82rem; color: var(--ink-3); }
160+.site-card .nodes { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
161+.site-card .nodes span { font-family: var(--mono); font-size: .7rem; padding: 2px 6px; border-radius: 5px; background: var(--surface-2); border: 1px solid var(--line); color: var(--ink-2); }
162+.site-card .nodes span.on { border-color: rgba(34,197,94,.35); color: #bbf7d0; }
163+
164+/* ---------- nodes list ---------- */
165+.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 18px 0 14px; }
166+.chips { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
114 .chips::-webkit-scrollbar { display: none; } 167 .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; } 168+.chip { flex: none; padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: .86rem; font-weight: 500; cursor: pointer; }
116−.chip .n { color: var(--ink-3); font-variant-numeric: tabular-nums; margin-left: 4px; } 169+.chip .n { color: var(--ink-3); font-family: var(--mono); margin-left: 5px; font-size: .78rem; }
117−.chip.active { background: var(--ink); color: #0a0f1c; border-color: transparent; } 170+.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); } .chip.active .n { color: #52525b; }
118−.chip.active .n { color: #46506b; } 171+.sort { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--ink-3); font-size: .82rem; }
119−.sort { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--ink-3); font-size: .85rem; } 172+.sort select { font: inherit; font-size: .86rem; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; }
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; } 173+.search { flex: 1 1 160px; min-width: 140px; font: inherit; font-size: .9rem; padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); }
121− 174+.group-sum { display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--ink-2); font-size: .86rem; margin-bottom: 14px; }
122−.group-summary { display: none; gap: 10px; margin-bottom: 16px; color: var(--ink-2); font-size: .92rem; } 175+.group-sum b { font-family: var(--mono); color: var(--ink); font-weight: 500; }
123−.group-summary.show { display: flex; flex-wrap: wrap; } 176+
124−.group-summary span { padding: 6px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-variant-numeric: tabular-nums; } 177+.rows { display: flex; flex-direction: column; gap: 6px; }
125− 178+.row { display: grid; grid-template-columns: 1fr auto; gap: 8px 12px; padding: 14px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); transition: border-color .15s, background .15s; align-items: center; }
126−/* nodes */ 179+.row:hover { border-color: var(--line-2); background: var(--surface-2); }
127−.nodes { display: grid; grid-template-columns: 1fr; gap: 12px; } 180+.row .id { font-family: var(--mono); font-weight: 600; font-size: 1.02rem; letter-spacing: -.01em; }
128−@media (min-width: 640px) { .nodes { grid-template-columns: repeat(2, 1fr); } } 181+.row .model { color: var(--ink-2); font-size: .82rem; }
129−@media (min-width: 1000px) { .nodes { grid-template-columns: repeat(3, 1fr); } } 182+.row .chipcol { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
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; } 183+.row .spec { display: flex; gap: 12px; font-family: var(--mono); font-size: .8rem; color: var(--ink-2); flex-wrap: wrap; }
131−.node:hover { border-color: var(--line-2); transform: translateY(-2px); } 184+.row .spec b { color: var(--ink); font-weight: 500; }
132−.node.tier-ultra { background: linear-gradient(180deg, rgba(192,132,252,.09), var(--surface)); } 185+.row .meters { display: grid; gap: 6px; grid-column: 1 / -1; }
133−.node.tier-max { background: linear-gradient(180deg, rgba(129,140,248,.08), var(--surface)); } 186+.row .st { justify-self: end; }
134−.node.tier-epyc, .node.tier-ryzen { background: linear-gradient(180deg, rgba(45,212,191,.09), var(--surface)); } 187+.row .site { color: var(--ink-3); font-size: .78rem; grid-column: 1 / -1; }
135−.node.is-off { opacity: .72; } 188+.row .arrow { display: none; color: var(--ink-3); }
136−.node-head { display: flex; align-items: flex-start; gap: 10px; } 189+@media (min-width: 980px) {
137−.node-id { font-size: 1.2rem; font-weight: 800; letter-spacing: -.02em; font-family: "SF Mono", ui-monospace, Menlo, monospace; } 190+ .row { grid-template-columns: 150px 150px 1fr 220px 120px 28px; padding: 12px 16px; }
138−.node-model { color: var(--ink-2); font-size: .88rem; } 191+ .row .meters { grid-column: auto; } .row .site { grid-column: auto; display: none; }
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; } 192+ .row .arrow { display: block; text-align: right; }
140−.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--off); } 193+ .row .chipcol { display: flex; }
141−.status.on { color: var(--good); border-color: rgba(52,211,153,.35); } 194+ .rows-head { display: grid; grid-template-columns: 150px 150px 1fr 220px 120px 28px; gap: 12px; padding: 0 16px 6px; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-3); }
142−.status.on::before { background: var(--good); } 195+}
143−.status.off { color: var(--ink-3); } 196+.rows-head { display: none; } @media (min-width: 980px) { .rows-head { display: grid; } }
144−.status.off::before { background: var(--off); } 197+
145−.status.na { color: var(--ink-3); border-style: dashed; } 198+/* ---------- node detail ---------- */
146−.badges { display: flex; flex-wrap: wrap; gap: 6px; } 199+.node-hero { display: grid; grid-template-columns: 1fr; gap: 16px; padding-top: 10px; }
147−.badge { font-size: .72rem; padding: 3px 9px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); } 200+@media (min-width: 860px) { .node-hero { grid-template-columns: 1fr auto; align-items: start; } }
148−.badge.chip-ultra { color: #e9d5ff; border-color: rgba(192,132,252,.45); } 201+.node-title { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
149−.badge.chip-max { color: #c7d2fe; border-color: rgba(129,140,248,.45); } 202+.node-title h1 { font-family: var(--mono); font-size: clamp(2rem, 6vw, 3.2rem); letter-spacing: -.03em; font-weight: 600; }
150−.badge.chip-pro { color: #a7f3d0; border-color: rgba(52,211,153,.35); } 203+.node-sub { color: var(--ink-2); margin-top: 6px; font-size: 1.02rem; }
151−.badge.chip-epyc, .badge.chip-ryzen { color: #99f6e4; border-color: rgba(45,212,191,.45); } 204+.node-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
152−.badge.role { color: var(--teal); } 205+.node-actions { display: flex; gap: 8px; flex-wrap: wrap; }
153−.specs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } 206+.specs { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; background: var(--surface); margin-top: 22px; }
154−.spec { background: rgba(0,0,0,.25); border-radius: var(--r-s); padding: 8px 6px; text-align: center; } 207+.specs div { padding: 14px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
155−.spec b { display: block; font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; } 208+.specs b { display: block; font-family: var(--mono); font-size: 1.25rem; font-weight: 600; letter-spacing: -.02em; }
156−.spec span { display: block; font-size: .66rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; } 209+.specs span { display: block; color: var(--ink-3); font-size: .74rem; margin-top: 3px; text-transform: uppercase; letter-spacing: .06em; }
157−.meters { display: grid; gap: 8px; } 210+@media (min-width: 640px) { .specs { grid-template-columns: repeat(3, 1fr); } }
158−.meter { display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 8px; font-size: .78rem; color: var(--ink-2); } 211+@media (min-width: 980px) { .specs { grid-template-columns: repeat(6, 1fr); } .specs div { border-bottom: 0; } }
159−.meter .bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; } 212+.detail-grid { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 10px; }
160−.meter .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--teal); transition: width .6s ease; } 213+@media (min-width: 980px) { .detail-grid { grid-template-columns: 2fr 1fr; } }
161−.meter .bar i.hi { background: var(--warn); } 214+.chart-wrap { margin-top: 8px; }
162−.meter .bar i.crit { background: var(--bad); } 215+.chart-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: .84rem; color: var(--ink-2); margin-bottom: 6px; }
163−.meter .v { font-variant-numeric: tabular-nums; color: var(--ink); min-width: 4.5ch; text-align: right; } 216+.chart-head b { color: var(--ink); font-family: var(--mono); font-weight: 500; }
164−.meter.na { color: var(--ink-3); } 217+.chart { width: 100%; height: 150px; display: block; touch-action: pan-y; }
165−.node-role { font-weight: 600; font-size: .95rem; } 218+.chart .grid-l { stroke: var(--line); stroke-width: 1; }
166−.node-desc { color: var(--ink-2); font-size: .88rem; } 219+.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
167−.node-apps { display: flex; flex-wrap: wrap; gap: 6px; } 220+.chart .area { fill: url(#areaGrad); }
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; } 221+.chart .axis { font-family: var(--mono); font-size: 10px; fill: var(--ink-3); }
169−.node-apps a.down { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.35); color: #fecaca; } 222+.chart .cross { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; }
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); } 223+.chart .pt { fill: var(--accent); stroke: var(--bg); stroke-width: 2; r: 4; opacity: 0; }
171−.node-foot span { display: inline-flex; align-items: center; gap: 5px; } 224+.chart-empty { height: 150px; display: grid; place-items: center; color: var(--ink-3); font-size: .84rem; border: 1px dashed var(--line); border-radius: var(--r-s); }
172−.node-foot code { font-size: .74rem; } 225+.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
173− 226+.seg button { font: inherit; font-size: .78rem; padding: 5px 10px; background: var(--surface); color: var(--ink-2); border: 0; cursor: pointer; font-family: var(--mono); }
174−/* apps */ 227+.seg button.active { background: var(--surface-3); color: var(--ink); }
175−.app-group { margin-bottom: 36px; } 228+.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: .88rem; }
176−.app-group-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px; margin-bottom: 14px; } 229+.kv dt { color: var(--ink-3); } .kv dd { margin: 0; color: var(--ink); font-family: var(--mono); font-size: .84rem; word-break: break-word; }
177−.app-group-head h3 { font-size: 1.35rem; } 230+.applist { display: flex; flex-direction: column; gap: 6px; }
178−.app-group-head .cnt { color: var(--ink-3); font-size: .9rem; font-variant-numeric: tabular-nums; } 231+.applist a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--r-s); border: 1px solid var(--line); background: var(--surface-2); }
179−.app-group-head p { flex-basis: 100%; color: var(--ink-2); font-size: .93rem; max-width: 760px; } 232+.applist a:hover { border-color: var(--line-2); }
180−.apps-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } 233+.applist .nm { font-weight: 600; font-size: .92rem; }
181−@media (min-width: 640px) { .apps-grid { grid-template-columns: repeat(2, 1fr); } } 234+.applist .dm { color: var(--ink-3); font-size: .76rem; font-family: var(--mono); margin-left: auto; text-align: right; }
182−@media (min-width: 1000px) { .apps-grid { grid-template-columns: repeat(3, 1fr); } } 235+.prose { color: var(--ink-2); font-size: .95rem; }
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; } 236+.prose strong { color: var(--ink); }
184−.app:hover { border-color: var(--line-2); transform: translateY(-2px); } 237+.pager { display: flex; justify-content: space-between; gap: 10px; margin-top: 28px; }
185−.app-top { display: flex; align-items: center; gap: 8px; } 238+.pager a { flex: 1; padding: 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); }
186−.app-name { font-weight: 700; font-size: 1.02rem; letter-spacing: -.01em; } 239+.pager a:hover { border-color: var(--line-2); }
187−.app-domain { color: var(--teal); font-size: .82rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; word-break: break-all; } 240+.pager a small { display: block; color: var(--ink-3); font-size: .74rem; margin-bottom: 4px; }
241+.pager a b { font-family: var(--mono); font-weight: 600; }
242+.pager a.next { text-align: right; }
243+
244+/* ---------- apps ---------- */
245+.app-group { margin-bottom: 34px; }
246+.app-group-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
247+.app-group-head h3 { font-size: 1.25rem; }
248+.app-group-head .cnt { color: var(--ink-3); font-family: var(--mono); font-size: .82rem; }
249+.app-group-head p { flex-basis: 100%; color: var(--ink-2); font-size: .92rem; max-width: 720px; }
250+.app { display: flex; flex-direction: column; gap: 8px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); transition: border-color .15s, background .15s; }
251+.app:hover { border-color: var(--line-2); background: var(--surface-2); }
252+.app-top { display: flex; align-items: center; gap: 10px; }
253+.app-name { font-weight: 600; font-size: 1rem; }
254+.app-top .status { margin-left: auto; }
255+.app-domain { color: var(--accent-2); font-family: var(--mono); font-size: .8rem; word-break: break-all; }
188 .app-domain.none { color: var(--ink-3); font-family: inherit; } 256 .app-domain.none { color: var(--ink-3); font-family: inherit; }
189−.app-desc { color: var(--ink-2); font-size: .88rem; margin-top: 8px; } 257+.app-desc { color: var(--ink-2); font-size: .88rem; }
190−.app-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: center; } 258+.app-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 2px; }
191−.app-meta .badge.nodetag { font-family: "SF Mono", ui-monospace, Menlo, monospace; } 259+.app-meta .ms { color: var(--ink-3); font-size: .74rem; font-family: var(--mono); }
192−.app-meta .ms { color: var(--ink-3); font-size: .74rem; font-variant-numeric: tabular-nums; } 260+
193− 261+/* ---------- map ---------- */
194−/* tunnel */ 262+.map-layout { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 18px; }
195−.gateways { display: grid; grid-template-columns: 1fr; gap: 12px; } 263+@media (min-width: 980px) { .map-layout { grid-template-columns: 1fr 340px; } }
196−@media (min-width: 900px) { .gateways { grid-template-columns: repeat(2, 1fr); } } 264+.map-box { position: relative; height: min(62vh, 560px); min-height: 360px; border-radius: var(--r); overflow: hidden; border: 1px solid var(--line); background: var(--surface); }
197−.gw { padding: 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } 265+.map-box .maplibregl-map { position: absolute; inset: 0; font-family: var(--sans); }
198−.gw.primary { border-color: rgba(45,212,191,.4); background: linear-gradient(180deg, rgba(45,212,191,.08), var(--surface)); } 266+.map-box .maplibregl-ctrl-attrib { background: rgba(9,9,11,.7) !important; color: var(--ink-3) !important; font-size: 10px; }
199−.gw-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } 267+.map-box .maplibregl-ctrl-attrib a { color: var(--ink-2) !important; }
200−.gw-head h3 { font-size: 1.2rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; } 268+.map-box .maplibregl-ctrl-group { background: var(--surface) !important; border: 1px solid var(--line-2) !important; box-shadow: none !important; }
201−.gw-meta { color: var(--ink-2); font-size: .9rem; margin-top: 6px; } 269+.map-box .maplibregl-ctrl-group button { background: transparent !important; } .map-box .maplibregl-ctrl-group button + button { border-top: 1px solid var(--line) !important; }
202−.gw-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 14px 0; } 270+.map-box .maplibregl-ctrl-group button .maplibregl-ctrl-icon { filter: invert(1) opacity(.8); }
203−.peers { display: flex; flex-wrap: wrap; gap: 6px; } 271+.map-box .maplibregl-popup-content { background: var(--surface-3); color: var(--ink); border: 1px solid var(--line-2); border-radius: 10px; padding: 10px 12px; font-size: .82rem; box-shadow: 0 10px 30px rgba(0,0,0,.5); }
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; } 272+.map-box .maplibregl-popup-tip { border-top-color: var(--surface-3) !important; border-bottom-color: var(--surface-3) !important; }
205−.peer::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--off); } 273+.map-box .maplibregl-popup-close-button { color: var(--ink-2); font-size: 16px; padding: 2px 6px; }
206−.peer.on::before { background: var(--good); } 274+.map-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--ink-3); font-size: .9rem; text-align: center; padding: 20px; }
207−.peers-title { font-size: .78rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; } 275+.pin { width: 14px; height: 14px; border-radius: 50%; background: var(--accent); border: 2px solid var(--bg); box-shadow: 0 0 0 4px rgba(96,165,250,.25); cursor: pointer; position: relative; }
208− 276+.pin.big { width: 20px; height: 20px; }
209−/* stack */ 277+.pin.ovh { background: #a78bfa; box-shadow: 0 0 0 4px rgba(167,139,250,.25); }
210−.stack { display: grid; grid-template-columns: 1fr; gap: 10px; } 278+.pin.rented { background: #34d399; box-shadow: 0 0 0 4px rgba(52,211,153,.25); }
211−@media (min-width: 640px) { .stack { grid-template-columns: repeat(2, 1fr); } } 279+.map-box.z-far .pin:not(.big) .lbl { opacity: 0; } .map-box.z-far .pin:not(.big):hover .lbl { opacity: 1; }
212−@media (min-width: 1000px) { .stack { grid-template-columns: repeat(3, 1fr); } } 280+.pin .lbl { transition: opacity .2s; position: absolute; left: 20px; top: -2px; font-family: var(--mono); font-size: 11px; color: var(--ink); white-space: nowrap; text-shadow: 0 1px 2px #000, 0 0 6px #000; pointer-events: none; }
213−.tool { padding: 16px 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); } 281+.pin.big .lbl { left: 26px; top: 1px; }
214−.tool h4 { font-size: 1rem; margin-bottom: 6px; } 282+.site-list { display: flex; flex-direction: column; gap: 6px; max-height: 560px; overflow: auto; }
215−.tool p { color: var(--ink-2); font-size: .9rem; } 283+.site-row { padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; text-align: left; font: inherit; color: inherit; }
216− 284+.site-row.active { border-color: var(--accent); }
217−/* footer */ 285+.site-row .t { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; }
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; } 286+.site-row .t .k { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; margin-right: 8px; }
219−.foot strong { color: var(--ink); } 287+.site-row .t .k.ovh { background: #a78bfa; } .site-row .t .k.rented { background: #34d399; }
220−.foot-links { display: flex; flex-wrap: wrap; gap: 8px 18px; } 288+.site-row .t .cnt { font-family: var(--mono); font-size: .78rem; color: var(--ink-2); font-weight: 500; }
221−.foot-links a { color: var(--ink-2); } 289+.site-row .r { color: var(--ink-3); font-size: .8rem; }
222−.foot-links a:hover { color: var(--ink); } 290+.site-row .m { color: var(--ink-2); font-size: .78rem; font-family: var(--mono); }
223− 291+.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: .78rem; color: var(--ink-2); margin-top: 10px; }
224−.reveal { opacity: 0; transform: translateY(14px); transition: opacity .6s ease, transform .6s ease; } 292+.legend span { display: inline-flex; align-items: center; gap: 6px; }
225−.reveal.in { opacity: 1; transform: none; } 293+.legend i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); } .legend i.ovh { background: #a78bfa; } .legend i.rented { background: #34d399; }
226−@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } } 294+
295+/* ---------- contact ---------- */
296+.contact-grid { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 24px; }
297+@media (min-width: 860px) { .contact-grid { grid-template-columns: 1.2fr .8fr; } }
298+.mail { display: inline-flex; align-items: center; gap: 12px; font-family: var(--mono); font-size: clamp(1.05rem, 3vw, 1.5rem); color: var(--ink); padding: 16px 20px; border: 1px solid var(--line-2); border-radius: var(--r); background: var(--surface); word-break: break-all; }
299+.mail:hover { border-color: var(--accent); }
300+.mail svg { width: 22px; height: 22px; color: var(--accent); flex: none; }
301+.copy { font-size: .8rem; color: var(--ink-3); margin-top: 10px; }
302+.copy button { font: inherit; color: var(--accent-2); background: none; border: 0; cursor: pointer; padding: 0; }
303+
304+/* ---------- footer ---------- */
305+.foot { margin-top: clamp(64px, 10vw, 120px); border-top: 1px solid var(--line); background: var(--bg-2); }
306+.foot-in { max-width: var(--maxw); margin: 0 auto; padding: 36px var(--pad) 20px; display: grid; grid-template-columns: 1fr; gap: 24px; }
307+@media (min-width: 760px) { .foot-in { grid-template-columns: 1.6fr 1fr 1fr 1fr; } }
308+.foot-brand p { color: var(--ink-3); font-size: .86rem; margin-top: 10px; max-width: 320px; }
309+.foot-col { display: flex; flex-direction: column; gap: 8px; }
310+.foot-col h4 { font-size: .74rem; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); margin-bottom: 4px; font-weight: 600; }
311+.foot-col a { color: var(--ink-2); font-size: .9rem; } .foot-col a:hover { color: var(--ink); }
312+.foot-meta { max-width: var(--maxw); margin: 0 auto; padding: 14px var(--pad) calc(24px + env(safe-area-inset-bottom)); color: var(--ink-3); font-size: .76rem; font-family: var(--mono); border-top: 1px solid var(--line); }
313+
314+.fade { animation: fade .35s ease both; }
315+@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
316+@media (prefers-reduced-motion: reduce) { .fade { animation: none; } * { transition: none !important; } }
317+.empty { padding: 40px; text-align: center; color: var(--ink-3); }
modified server.mjs +106 −55
Large diff (231 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme