SPB Git forge

spb/maclustr-www

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

maclustr-www 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 13 days ago (Sep 11, 2026) parent 8031ce2

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 2 (function () {
3 3 const D = window.MACLUSTR;
4 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 6 const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
7 7 const fmt = n => new Intl.NumberFormat("fr-CA").format(n);
8 − const GROUP_LABEL = { lan: "LAN Saint-Augustin", rented: "Mac dédié loué", ovh: "OVHcloud (Linux)" };
9 −
10 − let live = null;
11 − let filter = "all";
12 − let sortKey = "cores";
13 − const appsByNode = {};
14 − D.apps.forEach(a => (appsByNode[a.node] ||= []).push(a));
8 + const fmt1 = n => new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1 }).format(n);
9 + const GROUP_LABEL = { lan: "LAN Saint-Augustin", rented: "Mac dédié loué", ovh: "OVHcloud · Linux" };
10 + const GROUP_SHORT = { lan: "LAN", rented: "Loué", ovh: "OVH" };
11 + const view = $("#view");
12 + const byId = Object.fromEntries(D.nodes.map(n => [n.id, n]));
15 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 ----------
18 − function renderKPIs() {
17 + let live = null; // dernier /api/live
18 + let page = null; // { name, patch(), destroy() }
19 + const totals = (() => {
19 20 const macs = D.nodes.filter(n => n.group !== "ovh");
20 − const cores = D.nodes.reduce((s, n) => s + n.cores, 0);
21 − const gpu = macs.reduce((s, n) => s + (n.gpu || 0), 0);
22 − const ram = D.nodes.reduce((s, n) => s + n.ram, 0);
23 − const set = (k, v) => { const e = $(`[data-kpi="${k}"]`); if (e) e.textContent = v; };
24 − set("nodes", D.nodes.length);
25 − set("cores", fmt(cores));
26 − set("gpu", fmt(gpu));
27 − set("ram", ram >= 1000 ? (ram / 1024).toFixed(2).replace(".", ",") + " To" : ram + " Go");
28 − set("apps", D.apps.length);
29 − set("sites", D.apps.filter(a => a.domain).length);
30 − $("#nodeCountTitle").textContent = `${D.nodes.length} nœuds`;
31 − $("#appCountTitle").textContent = `${D.apps.length} applications`;
32 − ["all", "lan", "rented", "ovh"].forEach(g => { $(`#cnt-${g}`).textContent = g === "all" ? D.nodes.length : D.nodes.filter(n => n.group === g).length; });
33 − }
34 − function patchKPIsLive() {
35 − if (!live?.summary) return;
36 − const s = live.summary;
37 − const set = (k, v) => { const e = $(`[data-kpi="${k}"]`); if (e) e.textContent = v; };
38 − const macsOnline = s.nodesOnline, macsTotal = s.nodesTotal;
39 − set("nodesSub", `${macsOnline}/${macsTotal} Macs en ligne · ${D.nodes.filter(n => n.group === "ovh").length} Linux`);
40 − set("coresSub", `${fmt(s.coresOnline)} cœurs Mac en ligne`);
41 − if (s.ramOnlineGB) set("ramSub", `${fmt(s.ramUsedGB)} Go utilisés sur ${fmt(Math.round(s.ramOnlineGB))} Go Mac en ligne`);
42 − if (s.apps) set("appsSub", `${s.apps.up}/${s.apps.total} saines à l'instant`);
43 − }
44 −
45 − // ---------- Nodes ----------
46 − function nodeCard(n) {
47 − const c = el("article", `node tier-${n.tier}`);
48 − c.dataset.id = n.id; c.dataset.group = n.group;
49 − const disk = n.disk ? (n.disk >= 1000 ? (n.disk / 1000).toFixed(1).replace(".", ",") + " To" : n.disk + " Go") : "—";
50 − const apps = (appsByNode[n.id] || []).map(a => `<a href="#app-${a.id}" data-app="${a.id}">${esc(a.name)}</a>`).join("");
51 − const where = n.group === "lan" ? `<span>📍 Saint-Augustin</span><span><code>${esc(n.ip)}</code></span>`
52 − : `<span>📍 ${esc(n.location || "")}</span><span>🏢 ${esc(n.provider || "")}</span>`;
53 − c.innerHTML = `
54 − <div class="node-head">
55 − <div><div class="node-id">${esc(n.id)}</div><div class="node-model">${esc(n.model)}</div></div>
56 − <span class="status na" data-status>—</span>
57 − </div>
58 − <div class="badges">
59 − <span class="badge chip-${n.tier}">${esc(n.chip)}</span>
60 − <span class="badge">${GROUP_LABEL[n.group]}</span>
61 − ${n.tags.filter(t => ["gateway", "réservé", "headless", "agents", "monitoring", "europe", "réserve"].includes(t)).map(t => `<span class="badge role">${esc(t)}</span>`).join("")}
62 − </div>
63 − <div class="specs">
64 − <div class="spec"><b>${n.cores}</b><span>${n.threads ? "threads" : "cœurs"}</span></div>
65 − <div class="spec"><b>${n.gpu ? n.gpu : "—"}</b><span>GPU</span></div>
66 − <div class="spec"><b>${n.ram}</b><span>Go RAM</span></div>
67 − <div class="spec"><b>${disk}</b><span>disque</span></div>
68 − </div>
69 − ${n.group !== "ovh" ? `
70 − <div class="meters">
71 − <div class="meter na" data-m="cpu"><span>CPU</span><div class="bar"><i></i></div><span class="v">—</span></div>
72 − <div class="meter na" data-m="ram"><span>RAM</span><div class="bar"><i></i></div><span class="v">—</span></div>
73 − <div class="meter na" data-m="disk"><span>Disque</span><div class="bar"><i></i></div><span class="v">—</span></div>
74 − </div>` : ""}
75 − <div class="node-role">${esc(n.role)}</div>
76 − <div class="node-desc">${esc(n.desc)}</div>
77 − ${apps ? `<div class="node-apps">${apps}</div>` : ""}
78 − <div class="node-foot">${where}<span data-extra></span></div>`;
79 − return c;
80 − }
81 −
82 − function liveSort(a, b) {
83 − const la = live?.nodes?.[a.id], lb = live?.nodes?.[b.id];
84 − const va = la?.online ? la.cpu ?? -1 : -2, vb = lb?.online ? lb.cpu ?? -1 : -2;
85 − return vb - va;
86 − }
87 − function sortedNodes() {
88 − const list = D.nodes.filter(n => filter === "all" || n.group === filter);
89 − const cmp = { cores: (a, b) => b.cores - a.cores || b.ram - a.ram, ram: (a, b) => b.ram - a.ram || b.cores - a.cores, name: (a, b) => a.id.localeCompare(b.id, "fr", { sensitivity: "base" }), load: liveSort }[sortKey];
90 − return list.sort(cmp);
91 − }
92 − function renderNodes() {
93 − const host = $("#nodes"); host.innerHTML = "";
94 − sortedNodes().forEach(n => host.appendChild(nodeCard(n)));
95 − renderGroupSummary();
96 − patchNodesLive();
97 − }
98 − function renderGroupSummary() {
99 − const g = $("#groupSummary");
100 − if (filter === "all") { g.classList.remove("show"); return; }
101 − const list = D.nodes.filter(n => n.group === filter);
102 − const cores = list.reduce((s, n) => s + n.cores, 0), ram = list.reduce((s, n) => s + n.ram, 0), gpu = list.reduce((s, n) => s + (n.gpu || 0), 0);
103 − const desc = { lan: "Réseau local 192.168.2.x, domaine maclustr.io, clés SSH ed25519, découverte live des IP.", rented: "Macs dédiés loués chez Macly, MacStadium et rentamac. Hors LAN, joints par IP publique ou passerelle SSH, raccordés au tunnel. Réservés : déploiement seulement sur demande explicite.", ovh: "Serveurs bare metal Ubuntu 24.04 à Beauharnois (Québec) et Gravelines (France). Deux passerelles WireGuard + Caddy, deux serveurs de réserve." }[filter];
104 − g.innerHTML = `<span>${list.length} nœuds</span><span>${fmt(cores)} ${filter === "ovh" ? "threads" : "cœurs"}</span>${gpu ? `<span>${fmt(gpu)} cœurs GPU</span>` : ""}<span>${fmt(ram)} Go RAM</span><span style="border:0;background:none;padding-left:0">${desc}</span>`;
105 − g.classList.add("show");
106 − }
21 + return {
22 + nodes: D.nodes.length, macs: macs.length, ovh: D.nodes.length - macs.length,
23 + cores: D.nodes.reduce((s, n) => s + n.cores, 0), gpu: macs.reduce((s, n) => s + (n.gpu || 0), 0),
24 + ram: D.nodes.reduce((s, n) => s + n.ram, 0), disk: D.nodes.reduce((s, n) => s + (n.disk || 0), 0),
25 + apps: D.apps.length, sites: D.apps.filter(a => a.domain).length, places: Object.keys(D.sites).length,
26 + countries: new Set(Object.values(D.sites).map(s => s.region.split(",").pop().trim())).size,
27 + };
28 + })();
29 + const ramFmt = gb => gb >= 1000 ? fmt1(gb / 1024) + " To" : gb + " Go";
30 + const diskFmt = gb => gb ? (gb >= 1000 ? fmt1(gb / 1000) + " To" : gb + " Go") : "—";
107 31 const pct = (a, b) => (a != null && b) ? Math.min(100, Math.round(a / b * 100)) : null;
108 − function setMeter(card, key, p, label) {
109 − const m = card.querySelector(`[data-m="${key}"]`); if (!m) return;
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`; };
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 52 const bar = m.querySelector("i"), v = m.querySelector(".v");
111 53 if (p == null) { m.classList.add("na"); bar.style.width = "0"; v.textContent = "—"; return; }
112 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`; }
115 − function patchNodesLive() {
116 − document.querySelectorAll(".node").forEach(card => {
117 − const n = D.nodes.find(x => x.id === card.dataset.id);
118 − const st = card.querySelector("[data-status]"), extra = card.querySelector("[data-extra]");
119 − if (n.group === "ovh") {
120 − const gw = live?.tunnel?.find(g => g.name === n.id);
121 − if (gw) { st.className = `status ${gw.ok ? "on" : "off"}`; st.textContent = gw.ok ? "passerelle active" : "injoignable"; extra.textContent = gw.routes != null ? `${gw.routes} routes · ${gw.peers.filter(p => p.online).length} pairs` : ""; }
122 − else { st.className = "status on"; st.textContent = n.tags.includes("gateway") ? "passerelle" : "en réserve"; }
123 − return;
124 − }
125 − const l = live?.nodes?.[n.id];
126 − if (!live) { st.className = "status na"; st.textContent = "…"; return; }
127 − if (!l || !l.online) {
128 − st.className = "status off"; st.textContent = "hors ligne"; card.classList.add("is-off");
129 − ["cpu", "ram", "disk"].forEach(k => setMeter(card, k, null)); extra.textContent = ""; return;
130 − }
131 − card.classList.remove("is-off");
132 − st.className = "status on"; st.textContent = "en ligne";
133 − setMeter(card, "cpu", l.cpu != null ? Math.round(l.cpu) : null, `${Math.round(l.cpu)} %`);
134 − setMeter(card, "ram", pct(l.memUsedGB, l.memTotalGB), `${l.memUsedGB} / ${l.memTotalGB} Go`);
135 − setMeter(card, "disk", pct(l.diskUsedGB, l.diskTotalGB), `${fmt(l.diskUsedGB)} / ${fmt(l.diskTotalGB)} Go`);
136 − const bits = [];
137 − if (l.os) bits.push(`macOS ${l.os}`);
138 − if (l.uptimeS) bits.push(`⏱ ${uptime(l.uptimeS)}`);
139 − if (l.load1 != null) bits.push(`load ${l.load1}`);
140 − extra.textContent = bits.join(" · ");
141 − card.querySelectorAll(".node-apps a").forEach(a => {
142 − const la = live.apps?.[a.dataset.app];
143 − a.classList.toggle("down", !!la && la.state !== "up");
144 − });
145 − });
56 + function patchMeters(root, id) {
57 + const l = liveNode(id);
58 + if (!l || !l.online) { ["cpu", "ram", "disk"].forEach(k => setMeter(root, k, null)); return; }
59 + setMeter(root, "cpu", Math.round(l.cpu ?? 0), `${Math.round(l.cpu ?? 0)} %`);
60 + setMeter(root, "ram", pct(l.memUsedGB, l.memTotalGB), `${fmt1(l.memUsedGB)} / ${l.memTotalGB} Go`);
61 + setMeter(root, "disk", pct(l.diskUsedGB, l.diskTotalGB), `${fmt(l.diskUsedGB)} / ${fmt(l.diskTotalGB)} Go`);
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 ----------
149 − function renderApps() {
150 − const host = $("#appGroups"); host.innerHTML = "";
151 − D.appGroups.forEach(g => {
152 − const apps = D.apps.filter(a => a.group === g.id);
153 − const sec = el("div", "app-group");
154 − sec.innerHTML = `<div class="app-group-head"><h3>${esc(g.name)}</h3><span class="cnt">${apps.length} app${apps.length > 1 ? "s" : ""}</span><p>${esc(g.blurb)}</p></div>`;
155 − const grid = el("div", "apps-grid");
156 − apps.forEach(a => {
157 − const card = el(a.domain ? "a" : "div", "app");
158 − card.id = `app-${a.id}`; card.dataset.app = a.id;
159 − if (a.domain) { card.href = `https://${a.domain}/`; card.target = "_blank"; card.rel = "noopener"; }
160 − card.innerHTML = `
161 − <div class="app-top"><span class="app-name">${esc(a.name)}</span><span class="status na" data-status>—</span></div>
162 − <div class="app-domain ${a.domain ? "" : "none"}">${a.domain ? esc(a.domain) : "service interne"}</div>
163 − <p class="app-desc">${esc(a.desc)}</p>
164 − <div class="app-meta"><span class="badge nodetag" data-node>${esc(a.node)}</span><span class="badge">:${a.port}</span><span class="ms" data-ms></span></div>`;
165 − grid.appendChild(card);
166 − });
167 − sec.appendChild(grid); host.appendChild(sec);
72 + // tooltip global
73 + const tip = document.createElement("div"); tip.className = "tip"; document.body.appendChild(tip);
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"; }
75 + function hideTip() { tip.classList.remove("show"); }
76 +
77 + // ---------- pages ----------
78 + const pages = {};
79 +
80 + // ===== Accueil =====
81 + pages.home = () => {
82 + view.innerHTML = `
83 + <section class="hero fade">
84 + <p class="eyebrow">Cluster privé · Apple Silicon · Québec</p>
85 + <h1>Un supercalculateur<br><span class="dim">fait de Macs.</span></h1>
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>
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>
88 + </section>
89 +
90 + <section class="section tight">
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() {
172 − document.querySelectorAll(".app").forEach(card => {
173 − const a = appById[card.dataset.app]; const l = live?.apps?.[a.id];
174 − const st = card.querySelector("[data-status]"), ms = card.querySelector("[data-ms]"), nd = card.querySelector("[data-node]");
178 +
179 + // ===== Nœuds (liste) =====
180 + pages.nodes = () => {
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 332 if (!live) return;
176 − if (!l) { st.className = "status na"; st.textContent = "inconnu"; return; }
177 − if (l.node) nd.textContent = l.node;
178 − const up = l.state === "up";
179 − st.className = `status ${up ? "on" : l.state === "degraded" ? "na" : "off"}`;
180 − st.textContent = up ? "en ligne" : l.state === "degraded" ? "dégradée" : l.state === "down" ? "hors service" : (l.state || "—");
181 − const parts = [];
182 − if (l.publicMs != null && l.publicOk) parts.push(`${l.publicMs} ms public`);
183 − if (l.uptime24h != null) parts.push(`${Math.round(l.uptime24h)} % / 24 h`);
184 − if (l.via) parts.push(`via ${l.via}`);
185 − ms.textContent = parts.join(" · ");
186 − });
333 + set("ts", new Date(live.ts).toLocaleTimeString("fr-CA"));
334 + if (isMac) {
335 + const l = liveNode(n.id);
336 + patchMeters(view, n.id);
337 + if (l?.online) {
338 + set("os", l.os ? `${l.os}` : "—"); set("uptime", uptime(l.uptimeS)); set("load", `${l.load1}`); set("procs", fmt(l.procs || 0));
339 + set("net", `${kbs(l.netInKBs)} ↓ · ${kbs(l.netOutKBs)} ↑`); set("swap", `${fmt1(l.swapGB || 0)} Go`); set("thermal", l.thermal || "—");
340 + } else { ["os", "uptime", "load", "procs", "net", "swap", "thermal"].forEach(k => set(k, "—")); }
341 + const p = peerOf(n.id);
342 + set("peer", p ? `${p.online ? "raccordé" : "handshake ancien"} · ${p.gw} · ${bytes(p.rx)} ↑` : "non raccordé");
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 ----------
190 − function renderGateways() {
191 − const host = $("#gateways"); host.innerHTML = "";
192 − D.tunnel.gateways.forEach(g => {
193 − const n = D.nodes.find(x => x.id === g.id);
194 − const c = el("div", `gw ${g.primary ? "primary" : ""}`); c.dataset.gw = g.id;
195 − c.innerHTML = `
196 − <div class="gw-head"><h3>${esc(g.id)}</h3><span class="badge">${g.primary ? "passerelle principale" : "passerelle secondaire"}</span><span class="status na" data-status>—</span></div>
197 − <div class="gw-meta">${esc(n?.chip || "")} · ${n?.ram} Go · ${esc(g.site)} · <code>${esc(g.ip)}</code> · WireGuard <code>${esc(g.wg)}</code></div>
198 − <div class="gw-stats">
199 − <div class="spec"><b data-routes>—</b><span>routes HTTPS</span></div>
200 − <div class="spec"><b data-peers>—</b><span>pairs en ligne</span></div>
201 − <div class="spec"><b data-traffic>—</b><span>trafic sortant</span></div>
392 + // ===== Applications =====
393 + pages.apps = () => {
394 + view.innerHTML = `
395 + <section class="page-head fade">
396 + <p class="eyebrow">Hébergement</p>
397 + <h1>${totals.apps} applications en production.</h1>
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>
399 + </section>
400 + <section class="section tight" id="appsRoot">
401 + ${D.appGroups.map(g => { const apps = D.apps.filter(a => a.group === g.id); return `
402 + <div class="app-group" id="${g.id}">
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>
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 435 </div>
203 − <div class="peers-title">Pairs WireGuard</div>
204 − <div class="peers" data-peerlist><span class="peer">chargement…</span></div>`;
205 − host.appendChild(c);
206 − });
207 − patchGatewaysLive();
436 + </div>
437 + <section class="section tight">
438 + <div class="grid c2">
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("")}
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";
210 − function patchGatewaysLive() {
211 − if (!live?.tunnel) return;
212 − document.querySelectorAll(".gw").forEach(c => {
213 − const g = live.tunnel.find(x => x.name === c.dataset.gw); if (!g) return;
214 − const st = c.querySelector("[data-status]");
215 − st.className = `status ${g.ok ? "on" : "off"}`; st.textContent = g.ok ? "active" : "injoignable";
216 − const on = g.peers.filter(p => p.online);
217 − c.querySelector("[data-routes]").textContent = g.routes ?? "—";
218 − c.querySelector("[data-peers]").textContent = `${on.length}/${g.peers.length}`;
219 − c.querySelector("[data-traffic]").textContent = bytes(g.peers.reduce((s, p) => s + (p.rx || 0), 0));
220 − c.querySelector("[data-peerlist]").innerHTML = g.peers.sort((a, b) => a.alias.localeCompare(b.alias)).map(p => `<span class="peer ${p.online ? "on" : ""}" title="${p.online ? "handshake il y a " + p.handshakeS + " s" : "pas de handshake récent"}">${esc(p.alias)}</span>`).join("") || "<span class='peer'>aucun pair</span>";
496 + let mapLibP = null;
497 + function loadMapLibre() {
498 + if (window.maplibregl) return Promise.resolve();
499 + if (mapLibP) return mapLibP;
500 + mapLibP = new Promise((res, rej) => {
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);
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);
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 578 async function refresh() {
226 579 const pill = $("#livePill");
227 580 try {
228 − const r = await fetch("/api/live", { cache: "no-store" });
229 − const j = await r.json();
581 + const r = await fetch("/api/live", { cache: "no-store" }); const j = await r.json();
230 582 if (!j.summary) throw new Error(j.error || "sans données");
231 − live = j;
232 − pill.className = "live-pill ok";
233 − pill.querySelector(".txt").textContent = `live · ${j.summary.nodesOnline}/${j.summary.nodesTotal} Macs · ${j.summary.apps ? j.summary.apps.up + "/" + j.summary.apps.total + " apps" : ""}`;
234 − $("#footMeta").textContent = `Inventaire au ${new Date(D.updated).toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" })} · état live ${new Date(j.ts).toLocaleTimeString("fr-CA")}${j.stale ? " (cache)" : ""} · agent ${j.agentVersion || ""}`;
235 − patchKPIsLive(); patchNodesLive(); patchAppsLive(); patchGatewaysLive();
236 − if (sortKey === "load") renderNodes();
583 + live = j; 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" : ""}`;
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 || ""}`;
237 586 } catch (e) {
238 − pill.className = "live-pill bad";
239 − pill.querySelector(".txt").textContent = "live indisponible";
240 − if (!live) { live = { nodes: {}, apps: {}, tunnel: [], summary: null }; patchNodesLive(); live = null; }
587 + pill.className = "live-pill bad"; pill.querySelector(".txt").textContent = "live indisponible";
588 + if (!live) live = { ts: Date.now(), nodes: {}, apps: {}, tunnel: [], summary: null, dead: true };
241 589 }
590 + page?.patch?.();
242 591 }
243 −
244 − // ---------- UI ----------
245 − document.querySelectorAll(".chip[data-filter]").forEach(b => b.addEventListener("click", () => {
246 − document.querySelectorAll(".chip[data-filter]").forEach(x => { x.classList.remove("active"); x.setAttribute("aria-selected", "false"); });
247 − b.classList.add("active"); b.setAttribute("aria-selected", "true");
248 − filter = b.dataset.filter; renderNodes();
249 − }));
250 − $("#sortSel").addEventListener("change", e => { sortKey = e.target.value; renderNodes(); });
251 −
252 − // reveal on scroll
253 − const io = "IntersectionObserver" in window ? new IntersectionObserver(es => es.forEach(x => { if (x.isIntersecting) { x.target.classList.add("in"); io.unobserve(x.target); } }), { rootMargin: "0px 0px -8% 0px" }) : null;
254 − function observeReveal() { if (!io) return; document.querySelectorAll(".section > .sec-head, .stage, .pillar, .tool, .gw").forEach(e => { if (!e.classList.contains("reveal")) { e.classList.add("reveal"); io.observe(e); } }); }
255 −
256 − renderKPIs(); renderNodes(); renderApps(); renderGateways(); observeReveal();
257 − refresh(); setInterval(refresh, 30000);
592 + route(); refresh(); setInterval(refresh, 30000);
258 593 document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
259 594 })();
modified public/data.js +50 −38
@@ -2,87 +2,99 @@
2 2 Les états live (en ligne, charge, RAM, santé des apps) viennent de /api/live. */
3 3 window.MACLUSTR = {
4 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 17 totals: { macs: 33, ovh: 4, apps: 36, sites: 35 },
6 18
7 19 // group: lan | rented | ovh
8 20 nodes: [
9 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 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 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 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 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 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 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 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 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 35 role:"Applications Groupe Ka + index", desc:"API·Ka (Postgres 17 + Redis), Groupe Ka, Immo·Ka, Auto·Ka, CancerIndex, WebSensor, UQO IMM1003.",
24 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 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 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 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 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 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 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 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 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 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"] },
43 − { id:"m4mb", group:"lan", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, ip:"192.168.2.74", role:"Nœud de calcul", desc:"Mac mini M4, nœud générique.", tags:["worker"] },
44 − { id:"m4md", group:"lan", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, ip:"192.168.2.110", role:"Nœud de calcul", desc:"Mac mini M4 ajouté le 4 septembre 2026.", tags:["worker"] },
45 − { id:"m2m16", group:"lan", model:"Mac mini", chip:"M2 Pro", tier:"pro", cores:10, gpu:16, ram:16, disk:460, ip:"192.168.2.85", role:"Nœud de calcul", desc:"Mac mini M2 Pro, nœud générique.", tags:["worker"] },
46 − { id:"M3BA24", group:"lan", model:"MacBook Air", chip:"M3", tier:"base", cores:8, gpu:10, ram:24, disk:460, ip:"192.168.2.81", role:"Nœud d'appoint", desc:"MacBook Air, nœud d'appoint pour le calcul léger.", tags:["worker"] },
47 − { id:"M3BA16", group:"lan", model:"MacBook Air", chip:"M3", tier:"base", cores:8, gpu:10, ram:16, disk:460, ip:"192.168.2.70", role:"Nœud d'appoint", desc:"MacBook Air, nœud d'appoint.", tags:["worker"] },
48 − { id:"m2m8a", group:"lan", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:8, disk:460, ip:"192.168.2.84", role:"Nœud léger", desc:"Mac mini M2 8 Go : tâches légères et tests.", tags:["worker"] },
49 − { id:"m2m8b", group:"lan", model:"Mac mini", chip:"M2", tier:"base", cores:8, gpu:10, ram:8, disk:228, ip:"192.168.2.72", role:"Nœud léger", desc:"Mac mini M2 8 Go : tâches légères et tests.", tags:["worker"] },
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"] },
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"] },
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"] },
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"] },
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"] },
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"] },
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"] },
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 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 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 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 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 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 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 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",
65 − role:"Nœud distant (Europe)", desc:"Derrière NAT, joint par la passerelle SSH du fournisseur. Xcode complet préinstallé.", tags:["distant","réservé","europe"] },
66 − { id:"m4mh", group:"rented", model:"Mac mini", chip:"M4", tier:"base", cores:10, gpu:10, ram:16, disk:228, provider:"Macly", location:"Centre de données Macly",
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",
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"] },
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 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 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 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 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 87 role:"Nœud distant", desc:"Mac mini M4 dédié, ajouté le 10 septembre 2026.", tags:["distant","réservé"] },
76 88
77 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 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 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 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 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 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 99 tags:["gateway","wireguard","caddy","linux","europe"] }
88 100 ],
modified public/index.html +35 −162
@@ -3,181 +3,54 @@
3 3 <head>
4 4 <meta charset="utf-8">
5 5 <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 −<title>MacLustr — Le cluster Apple Silicon de Saint-Augustin</title>
7 −<meta name="description" content="MacLustr : 33 Macs Apple Silicon, 4 serveurs OVHcloud, 484 cœurs, 1,4 To de RAM. Un cluster privé qui héberge 36 applications en production, exposées au monde par le MacLustr Tunnel.">
8 −<meta name="theme-color" content="#070a12">
6 +<title>MacLustr — Le cluster Apple Silicon</title>
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="#09090b">
9 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 11 <meta property="og:type" content="website">
12 12 <meta property="og:url" content="https://www.maclustr.io/">
13 13 <link rel="icon" href="/favicon.svg" type="image/svg+xml">
14 14 <link rel="stylesheet" href="/styles.css">
15 15 </head>
16 16 <body>
17 −<div class="bg" aria-hidden="true"><div class="orb o1"></div><div class="orb o2"></div><div class="orb o3"></div><div class="grid-lines"></div></div>
18 −
17 +<a class="skip" href="#view">Aller au contenu</a>
19 18 <header class="top">
20 − <a class="brand" href="#top" aria-label="MacLustr">
21 − <svg class="logo" viewBox="0 0 32 32" aria-hidden="true"><defs><linearGradient id="lg" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#2dd4bf"/><stop offset="1" stop-color="#818cf8"/></linearGradient></defs><rect x="2" y="2" width="12" height="12" rx="3" fill="url(#lg)"/><rect x="18" y="2" width="12" height="12" rx="3" fill="url(#lg)" opacity=".75"/><rect x="2" y="18" width="12" height="12" rx="3" fill="url(#lg)" opacity=".75"/><rect x="18" y="18" width="12" height="12" rx="3" fill="url(#lg)" opacity=".5"/></svg>
22 − <span>MacLustr</span>
23 − </a>
24 − <nav class="nav" aria-label="Sections">
25 − <a href="#architecture">Architecture</a>
26 − <a href="#noeuds">Nœuds</a>
27 − <a href="#apps">Applications</a>
28 − <a href="#tunnel">Tunnel</a>
29 − <a href="#pile">Pile</a>
19 + <div class="top-in">
20 + <a class="brand" href="/" data-link aria-label="MacLustr, accueil">
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>
22 + <span>MacLustr</span>
23 + </a>
24 + <nav class="nav" id="nav" aria-label="Pages">
25 + <a href="/" data-link data-route="home">Accueil</a>
26 + <a href="/nodes" data-link data-route="nodes">Nœuds</a>
27 + <a href="/apps" data-link data-route="apps">Applications</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 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 41 </header>
33 42
34 −<main id="top">
35 − <!-- ============ HERO ============ -->
36 − <section class="hero">
37 − <p class="eyebrow">Cluster privé · Apple Silicon · Saint-Augustin-de-Desmaures, Québec</p>
38 − <h1>Un supercalculateur<br><span class="grad">fait de Macs.</span></h1>
39 − <p class="lede">MacLustr réunit <strong>33 Macs Apple Silicon</strong> et <strong>4 serveurs OVHcloud</strong> en une seule plateforme : orchestration automatique, tunnel public chiffré, supervision en continu. Elle héberge <strong>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>
43 +<main id="view" class="view" tabindex="-1"></main>
171 44
172 45 <footer class="foot">
173 − <div>
174 − <strong>MacLustr</strong> · cluster privé de Simon-Pierre Boucher · Saint-Augustin-de-Desmaures, Québec
175 − </div>
176 − <div class="foot-links">
177 − <a href="https://www.spboucher.ai" rel="noopener">spboucher.ai</a>
178 − <a href="https://git.spboucher.ai" rel="noopener">spbgit</a>
179 − <a href="https://www.groupe-ka.com" rel="noopener">Groupe Ka</a>
180 − <a href="https://www.hfmarketdata.io" rel="noopener">HF Market Data</a>
46 + <div class="foot-in">
47 + <div class="foot-brand">
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>
49 + <p>Cluster privé Apple Silicon de Simon-Pierre Boucher. Saint-Augustin-de-Desmaures, Québec.</p>
50 + </div>
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>
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>
53 + <div class="foot-col"><h4>Contact</h4><a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a></div>
181 54 </div>
182 55 <div class="foot-meta" id="footMeta">Inventaire au 10 septembre 2026.</div>
183 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 2 :root {
3 − --bg: #070a12;
4 − --bg-2: #0b1020;
5 − --surface: rgba(255,255,255,.045);
6 − --surface-2: rgba(255,255,255,.075);
7 − --line: rgba(255,255,255,.10);
8 − --line-2: rgba(255,255,255,.18);
9 − --ink: #eef2ff;
10 − --ink-2: #aab3cc;
11 − --ink-3: #6f7a99;
12 − --teal: #2dd4bf;
13 − --indigo: #818cf8;
14 − --violet: #c084fc;
15 − --grad: linear-gradient(100deg, var(--teal), var(--indigo) 55%, var(--violet));
16 − --good: #34d399;
17 − --warn: #fbbf24;
18 − --bad: #f87171;
19 − --off: #64748b;
20 − --r: 18px;
21 − --r-s: 10px;
22 − --pad: clamp(16px, 4vw, 40px);
23 − --maxw: 1240px;
24 − font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", Roboto, sans-serif;
3 + --bg: #09090b; --bg-2: #0f0f12; --surface: #111114; --surface-2: #17171b; --surface-3: #1e1e24;
4 + --line: #1f1f26; --line-2: #2c2c35;
5 + --ink: #f4f4f5; --ink-2: #a1a1aa; --ink-3: #6b6b76;
6 + --accent: #60a5fa; --accent-2: #93c5fd; --accent-dim: rgba(96,165,250,.14);
7 + --good: #22c55e; --warn: #f59e0b; --bad: #ef4444; --off: #52525b;
8 + --r: 12px; --r-s: 8px;
9 + --pad: clamp(16px, 4vw, 40px); --maxw: 1200px;
10 + --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, "Segoe UI", Roboto, sans-serif;
11 + --mono: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
25 12 color-scheme: dark;
26 13 }
27 14 * { box-sizing: border-box; }
28 −html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
29 −body { margin: 0; background: var(--bg); color: var(--ink); line-height: 1.55; overflow-x: hidden; -webkit-font-smoothing: antialiased; }
15 +html { -webkit-text-size-adjust: 100%; }
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 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; }
32 −h1, h2, h3, h4 { margin: 0; line-height: 1.12; letter-spacing: -.02em; font-weight: 700; }
19 +h1, h2, h3, h4 { margin: 0; line-height: 1.1; letter-spacing: -.025em; font-weight: 650; }
33 20 p { margin: 0; }
34 −.grad { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
35 −.eyebrow { font-size: .78rem; text-transform: uppercase; letter-spacing: .14em; color: var(--teal); font-weight: 600; margin-bottom: 12px; }
36 −
37 −/* background */
38 −.bg { position: fixed; inset: 0; z-index: -1; overflow: hidden; background: radial-gradient(1200px 600px at 70% -10%, #131a35 0%, transparent 60%), var(--bg); }
39 −.orb { position: absolute; border-radius: 50%; filter: blur(80px); opacity: .28; animation: drift 24s ease-in-out infinite alternate; }
40 −.o1 { width: 520px; height: 520px; left: -160px; top: -120px; background: var(--teal); }
41 −.o2 { width: 620px; height: 620px; right: -200px; top: 20vh; background: var(--indigo); animation-delay: -8s; }
42 −.o3 { width: 460px; height: 460px; left: 30vw; top: 120vh; background: var(--violet); animation-delay: -16s; }
43 −@keyframes drift { from { transform: translate3d(0,0,0) scale(1); } to { transform: translate3d(60px, 40px, 0) scale(1.08); } }
44 −.grid-lines { position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px); background-size: 56px 56px; mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 70%); -webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 70%); }
45 −@media (prefers-reduced-motion: reduce) { .orb { animation: none; } html { scroll-behavior: auto; } }
46 −
47 −/* header */
48 −.top { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; gap: 14px; padding: 10px var(--pad); padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(7,10,18,.65); backdrop-filter: saturate(160%) blur(18px); -webkit-backdrop-filter: saturate(160%) blur(18px); border-bottom: 1px solid var(--line); }
49 −.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 1.12rem; }
50 −.logo { width: 26px; height: 26px; }
51 −.nav { display: none; gap: 4px; margin-left: 10px; }
52 −.nav a { padding: 7px 12px; border-radius: 999px; color: var(--ink-2); font-size: .92rem; font-weight: 500; }
53 −.nav a:hover { background: var(--surface-2); color: var(--ink); }
54 −.live-pill { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--ink-2); white-space: nowrap; }
55 −.live-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--off); box-shadow: 0 0 0 0 rgba(52,211,153,.5); }
56 −.live-pill.ok .dot { background: var(--good); animation: pulse 2s infinite; }
21 +code, .mono { font-family: var(--mono); font-size: .92em; }
22 +code { background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; padding: .06em .4em; color: var(--accent-2); }
23 +.num { font-variant-numeric: tabular-nums; }
24 +.muted { color: var(--ink-2); } .faint { color: var(--ink-3); }
25 +.skip { position: absolute; left: -999px; top: 8px; background: var(--ink); color: var(--bg); padding: 8px 12px; border-radius: 8px; z-index: 100; }
26 +.skip:focus { left: 8px; }
27 +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
28 +
29 +/* ---------- header ---------- */
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); }
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); }
32 +.brand { display: inline-flex; align-items: center; gap: 9px; font-weight: 700; letter-spacing: -.02em; font-size: 1.05rem; color: var(--ink); }
33 +.logo { width: 22px; height: 22px; color: var(--accent); }
34 +.nav { display: none; gap: 2px; margin-left: 18px; }
35 +.nav a, .drawer a { padding: 7px 12px; border-radius: 8px; color: var(--ink-2); font-size: .92rem; font-weight: 500; }
36 +.nav a:hover { color: var(--ink); background: var(--surface-2); }
37 +.nav a.active, .drawer a.active { color: var(--ink); background: var(--surface-3); }
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); }
39 +.live-pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--off); }
40 +.live-pill.ok .dot { background: var(--good); box-shadow: 0 0 0 3px rgba(34,197,94,.18); }
57 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); } }
59 −@media (min-width: 900px) { .nav { display: flex; } }
60 −
61 −/* layout */
62 −main { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); }
63 −.section { padding: clamp(56px, 9vw, 110px) 0 0; }
64 −.sec-head { max-width: 760px; margin-bottom: clamp(24px, 4vw, 40px); }
65 −.sec-head h2 { font-size: clamp(1.7rem, 4.6vw, 2.7rem); }
66 −.sec-head .sub { margin-top: 14px; color: var(--ink-2); font-size: clamp(.98rem, 1.6vw, 1.1rem); }
67 −
68 −/* hero */
69 −.hero { padding: clamp(48px, 10vw, 120px) 0 0; }
70 −.hero h1 { font-size: clamp(2.5rem, 8.5vw, 5.4rem); letter-spacing: -.035em; font-weight: 800; }
71 −.lede { margin-top: 22px; max-width: 720px; color: var(--ink-2); font-size: clamp(1.02rem, 1.9vw, 1.22rem); }
72 −.lede strong { color: var(--ink); font-weight: 600; }
73 −.cta-row { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
74 −.btn { display: inline-flex; align-items: center; justify-content: center; padding: 12px 20px; border-radius: 999px; font-weight: 600; font-size: .95rem; border: 1px solid var(--line-2); transition: transform .15s ease, background .15s ease; }
75 −.btn:active { transform: scale(.97); }
76 −.btn.primary { background: var(--grad); color: #06101a; border-color: transparent; }
77 −.btn.ghost { background: var(--surface); }
78 −.btn.ghost:hover { background: var(--surface-2); }
79 −
80 −.kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: clamp(36px, 6vw, 64px); }
81 −.kpi { padding: 16px 16px 14px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); position: relative; overflow: hidden; }
82 −.kpi::after { content: ""; position: absolute; inset: auto 0 0 0; height: 2px; background: var(--grad); opacity: .7; }
83 −.kpi-v { font-size: clamp(1.7rem, 5vw, 2.4rem); font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums; line-height: 1.05; }
84 −.kpi-l { color: var(--ink-2); font-size: .9rem; margin-top: 2px; }
85 −.kpi-s { color: var(--ink-3); font-size: .74rem; margin-top: 6px; }
86 −@media (min-width: 640px) { .kpis { grid-template-columns: repeat(3, 1fr); } }
87 −@media (min-width: 1000px) { .kpis { grid-template-columns: repeat(6, 1fr); } }
88 −
89 −/* flow */
90 −.flow { display: grid; grid-template-columns: 1fr; gap: 6px; }
91 −.stage { padding: 20px; border-radius: var(--r); background: var(--surface); border: 1px solid var(--line); }
92 −.stage.accent { border-color: rgba(129,140,248,.45); background: linear-gradient(180deg, rgba(129,140,248,.10), rgba(45,212,191,.05)); }
93 −.stage-ico { font-size: 1.5rem; margin-bottom: 10px; }
94 −.stage h3 { font-size: 1.08rem; margin-bottom: 8px; }
95 −.stage p { color: var(--ink-2); font-size: .93rem; }
96 −.arrow { width: 2px; height: 22px; margin: 0 auto; background: linear-gradient(180deg, var(--teal), var(--indigo)); position: relative; opacity: .8; }
97 −.arrow::after { content: ""; position: absolute; bottom: -1px; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top: 8px solid var(--indigo); }
98 −@media (min-width: 1000px) {
99 − .flow { grid-template-columns: 1fr 26px 1fr 26px 1fr 26px 1fr 26px 1fr; align-items: stretch; }
100 − .arrow { width: 26px; height: 2px; margin: auto 0; align-self: center; background: linear-gradient(90deg, var(--teal), var(--indigo)); }
101 − .arrow::after { bottom: auto; left: auto; right: -2px; top: 50%; transform: translateY(-50%); border: 6px solid transparent; border-left: 8px solid var(--indigo); }
102 −}
103 −.pillars { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 28px; }
104 −.pillar { padding: 18px 20px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); }
105 −.pillar .pi { font-size: 1.3rem; }
106 −.pillar h4 { font-size: 1rem; margin: 8px 0 6px; }
107 −.pillar p { color: var(--ink-2); font-size: .92rem; }
108 −@media (min-width: 720px) { .pillars { grid-template-columns: repeat(2, 1fr); } }
109 −@media (min-width: 1100px) { .pillars { grid-template-columns: repeat(4, 1fr); } }
110 −
111 −/* toolbar */
112 −.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 18px; position: sticky; top: calc(56px + env(safe-area-inset-top)); z-index: 5; padding: 10px 0; background: linear-gradient(180deg, rgba(7,10,18,.92), rgba(7,10,18,.75)); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
113 −.chips { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; padding-bottom: 2px; }
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; }
43 +.burger span { display: block; height: 2px; background: var(--ink); border-radius: 2px; transition: transform .2s; }
44 +.burger[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
45 +.burger[aria-expanded="true"] span:last-child { transform: translateY(-3.5px) rotate(-45deg); }
46 +.drawer { display: none; flex-direction: column; padding: 8px var(--pad) 14px; border-top: 1px solid var(--line); background: var(--bg); }
47 +.drawer.open { display: flex; }
48 +.drawer a { padding: 12px 10px; font-size: 1rem; }
49 +@media (min-width: 860px) { .nav { display: flex; } .burger, .drawer { display: none !important; } .live-pill { font-size: .78rem; } }
50 +@media (max-width: 420px) { .live-pill .txt { max-width: 150px; overflow: hidden; text-overflow: ellipsis; } }
51 +
52 +/* ---------- layout ---------- */
53 +.view { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--pad); min-height: 60vh; }
54 +.section { padding: clamp(44px, 7vw, 88px) 0 0; }
55 +.section.tight { padding-top: clamp(28px, 4vw, 48px); }
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); }
57 +.sec-head h2 { font-size: clamp(1.45rem, 3.4vw, 2rem); }
58 +.sec-head .sub { color: var(--ink-2); max-width: 680px; margin-top: 8px; font-size: .98rem; }
59 +.sec-head .more { color: var(--accent-2); font-size: .92rem; font-weight: 500; white-space: nowrap; }
60 +.eyebrow { font-size: .74rem; text-transform: uppercase; letter-spacing: .12em; color: var(--ink-3); font-weight: 600; margin-bottom: 10px; }
61 +.page-head { padding: clamp(36px, 6vw, 72px) 0 0; }
62 +.page-head h1 { font-size: clamp(2rem, 5.6vw, 3.4rem); letter-spacing: -.035em; }
63 +.page-head .lede { color: var(--ink-2); max-width: 720px; margin-top: 14px; font-size: clamp(1rem, 1.6vw, 1.12rem); }
64 +.crumbs { display: flex; gap: 8px; align-items: center; font-size: .84rem; color: var(--ink-3); margin-bottom: 14px; flex-wrap: wrap; }
65 +.crumbs a { color: var(--ink-2); } .crumbs a:hover { color: var(--ink); }
66 +
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; }
68 +.btn:hover { background: var(--surface-2); border-color: #3a3a46; }
69 +.btn:active { transform: scale(.98); }
70 +.btn.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
71 +.btn.primary:hover { background: #fff; }
72 +.btn.small { padding: 7px 12px; font-size: .84rem; border-radius: 8px; }
73 +
74 +.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r); }
75 +.card.pad { padding: 18px; }
76 +.grid { display: grid; gap: 10px; grid-template-columns: 1fr; }
77 +@media (min-width: 640px) { .grid.c2, .grid.c3, .grid.c4 { grid-template-columns: repeat(2, 1fr); } }
78 +@media (min-width: 980px) { .grid.c3 { grid-template-columns: repeat(3, 1fr); } .grid.c4 { grid-template-columns: repeat(4, 1fr); } }
79 +
80 +/* status */
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); }
82 +.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--off); flex: none; }
83 +.status.on { color: #86efac; border-color: rgba(34,197,94,.35); background: rgba(34,197,94,.08); } .status.on::before { background: var(--good); }
84 +.status.warn { color: #fcd34d; border-color: rgba(245,158,11,.35); } .status.warn::before { background: var(--warn); }
85 +.status.off { color: var(--ink-3); } .status.off::before { background: var(--off); }
86 +.status.bad { color: #fca5a5; border-color: rgba(239,68,68,.35); } .status.bad::before { background: var(--bad); }
87 +.status.na { border-style: dashed; }
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; }
89 +.tag.accent { color: var(--accent-2); background: var(--accent-dim); border-color: rgba(96,165,250,.3); }
90 +.tag.mono { font-family: var(--mono); }
91 +
92 +/* meters */
93 +.meter { display: grid; grid-template-columns: 52px 1fr auto; align-items: center; gap: 10px; font-size: .8rem; color: var(--ink-2); }
94 +.meter .bar { height: 5px; border-radius: 999px; background: var(--surface-3); overflow: hidden; }
95 +.meter .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--accent); transition: width .6s ease; }
96 +.meter .bar i.hi { background: var(--warn); } .meter .bar i.crit { background: var(--bad); }
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 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; }
116 −.chip .n { color: var(--ink-3); font-variant-numeric: tabular-nums; margin-left: 4px; }
117 −.chip.active { background: var(--ink); color: #0a0f1c; border-color: transparent; }
118 −.chip.active .n { color: #46506b; }
119 −.sort { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--ink-3); font-size: .85rem; }
120 −.sort select { font: inherit; font-size: .9rem; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 7px 12px; }
121 −
122 −.group-summary { display: none; gap: 10px; margin-bottom: 16px; color: var(--ink-2); font-size: .92rem; }
123 −.group-summary.show { display: flex; flex-wrap: wrap; }
124 −.group-summary span { padding: 6px 12px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-variant-numeric: tabular-nums; }
125 −
126 −/* nodes */
127 −.nodes { display: grid; grid-template-columns: 1fr; gap: 12px; }
128 −@media (min-width: 640px) { .nodes { grid-template-columns: repeat(2, 1fr); } }
129 −@media (min-width: 1000px) { .nodes { grid-template-columns: repeat(3, 1fr); } }
130 −.node { border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); padding: 16px; display: flex; flex-direction: column; gap: 12px; transition: border-color .2s, transform .2s; position: relative; }
131 −.node:hover { border-color: var(--line-2); transform: translateY(-2px); }
132 −.node.tier-ultra { background: linear-gradient(180deg, rgba(192,132,252,.09), var(--surface)); }
133 −.node.tier-max { background: linear-gradient(180deg, rgba(129,140,248,.08), var(--surface)); }
134 −.node.tier-epyc, .node.tier-ryzen { background: linear-gradient(180deg, rgba(45,212,191,.09), var(--surface)); }
135 −.node.is-off { opacity: .72; }
136 −.node-head { display: flex; align-items: flex-start; gap: 10px; }
137 −.node-id { font-size: 1.2rem; font-weight: 800; letter-spacing: -.02em; font-family: "SF Mono", ui-monospace, Menlo, monospace; }
138 −.node-model { color: var(--ink-2); font-size: .88rem; }
139 −.status { margin-left: auto; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .74rem; font-weight: 600; border: 1px solid var(--line); white-space: nowrap; }
140 −.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--off); }
141 −.status.on { color: var(--good); border-color: rgba(52,211,153,.35); }
142 −.status.on::before { background: var(--good); }
143 −.status.off { color: var(--ink-3); }
144 −.status.off::before { background: var(--off); }
145 −.status.na { color: var(--ink-3); border-style: dashed; }
146 −.badges { display: flex; flex-wrap: wrap; gap: 6px; }
147 −.badge { font-size: .72rem; padding: 3px 9px; border-radius: 999px; background: var(--surface-2); color: var(--ink-2); border: 1px solid var(--line); }
148 −.badge.chip-ultra { color: #e9d5ff; border-color: rgba(192,132,252,.45); }
149 −.badge.chip-max { color: #c7d2fe; border-color: rgba(129,140,248,.45); }
150 −.badge.chip-pro { color: #a7f3d0; border-color: rgba(52,211,153,.35); }
151 −.badge.chip-epyc, .badge.chip-ryzen { color: #99f6e4; border-color: rgba(45,212,191,.45); }
152 −.badge.role { color: var(--teal); }
153 −.specs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
154 −.spec { background: rgba(0,0,0,.25); border-radius: var(--r-s); padding: 8px 6px; text-align: center; }
155 −.spec b { display: block; font-size: 1rem; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
156 −.spec span { display: block; font-size: .66rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .06em; margin-top: 2px; }
157 −.meters { display: grid; gap: 8px; }
158 −.meter { display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 8px; font-size: .78rem; color: var(--ink-2); }
159 −.meter .bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,.08); overflow: hidden; }
160 −.meter .bar i { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--teal); transition: width .6s ease; }
161 −.meter .bar i.hi { background: var(--warn); }
162 −.meter .bar i.crit { background: var(--bad); }
163 −.meter .v { font-variant-numeric: tabular-nums; color: var(--ink); min-width: 4.5ch; text-align: right; }
164 −.meter.na { color: var(--ink-3); }
165 −.node-role { font-weight: 600; font-size: .95rem; }
166 −.node-desc { color: var(--ink-2); font-size: .88rem; }
167 −.node-apps { display: flex; flex-wrap: wrap; gap: 6px; }
168 −.node-apps a { font-size: .74rem; padding: 3px 9px; border-radius: 999px; background: rgba(45,212,191,.10); border: 1px solid rgba(45,212,191,.25); color: #99f6e4; }
169 −.node-apps a.down { background: rgba(248,113,113,.12); border-color: rgba(248,113,113,.35); color: #fecaca; }
170 −.node-foot { display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--ink-3); font-size: .76rem; margin-top: auto; padding-top: 4px; border-top: 1px dashed var(--line); }
171 −.node-foot span { display: inline-flex; align-items: center; gap: 5px; }
172 −.node-foot code { font-size: .74rem; }
173 −
174 −/* apps */
175 −.app-group { margin-bottom: 36px; }
176 −.app-group-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 14px; margin-bottom: 14px; }
177 −.app-group-head h3 { font-size: 1.35rem; }
178 −.app-group-head .cnt { color: var(--ink-3); font-size: .9rem; font-variant-numeric: tabular-nums; }
179 −.app-group-head p { flex-basis: 100%; color: var(--ink-2); font-size: .93rem; max-width: 760px; }
180 −.apps-grid { display: grid; grid-template-columns: 1fr; gap: 10px; }
181 −@media (min-width: 640px) { .apps-grid { grid-template-columns: repeat(2, 1fr); } }
182 −@media (min-width: 1000px) { .apps-grid { grid-template-columns: repeat(3, 1fr); } }
183 −.app { display: block; padding: 14px 16px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); transition: border-color .2s, transform .2s; }
184 −.app:hover { border-color: var(--line-2); transform: translateY(-2px); }
185 −.app-top { display: flex; align-items: center; gap: 8px; }
186 −.app-name { font-weight: 700; font-size: 1.02rem; letter-spacing: -.01em; }
187 −.app-domain { color: var(--teal); font-size: .82rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; word-break: break-all; }
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; }
169 +.chip .n { color: var(--ink-3); font-family: var(--mono); margin-left: 5px; font-size: .78rem; }
170 +.chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); } .chip.active .n { color: #52525b; }
171 +.sort { margin-left: auto; display: flex; align-items: center; gap: 8px; color: var(--ink-3); font-size: .82rem; }
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; }
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); }
174 +.group-sum { display: flex; flex-wrap: wrap; gap: 8px 14px; color: var(--ink-2); font-size: .86rem; margin-bottom: 14px; }
175 +.group-sum b { font-family: var(--mono); color: var(--ink); font-weight: 500; }
176 +
177 +.rows { display: flex; flex-direction: column; gap: 6px; }
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; }
179 +.row:hover { border-color: var(--line-2); background: var(--surface-2); }
180 +.row .id { font-family: var(--mono); font-weight: 600; font-size: 1.02rem; letter-spacing: -.01em; }
181 +.row .model { color: var(--ink-2); font-size: .82rem; }
182 +.row .chipcol { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
183 +.row .spec { display: flex; gap: 12px; font-family: var(--mono); font-size: .8rem; color: var(--ink-2); flex-wrap: wrap; }
184 +.row .spec b { color: var(--ink); font-weight: 500; }
185 +.row .meters { display: grid; gap: 6px; grid-column: 1 / -1; }
186 +.row .st { justify-self: end; }
187 +.row .site { color: var(--ink-3); font-size: .78rem; grid-column: 1 / -1; }
188 +.row .arrow { display: none; color: var(--ink-3); }
189 +@media (min-width: 980px) {
190 + .row { grid-template-columns: 150px 150px 1fr 220px 120px 28px; padding: 12px 16px; }
191 + .row .meters { grid-column: auto; } .row .site { grid-column: auto; display: none; }
192 + .row .arrow { display: block; text-align: right; }
193 + .row .chipcol { display: flex; }
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); }
195 +}
196 +.rows-head { display: none; } @media (min-width: 980px) { .rows-head { display: grid; } }
197 +
198 +/* ---------- node detail ---------- */
199 +.node-hero { display: grid; grid-template-columns: 1fr; gap: 16px; padding-top: 10px; }
200 +@media (min-width: 860px) { .node-hero { grid-template-columns: 1fr auto; align-items: start; } }
201 +.node-title { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
202 +.node-title h1 { font-family: var(--mono); font-size: clamp(2rem, 6vw, 3.2rem); letter-spacing: -.03em; font-weight: 600; }
203 +.node-sub { color: var(--ink-2); margin-top: 6px; font-size: 1.02rem; }
204 +.node-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
205 +.node-actions { display: flex; gap: 8px; flex-wrap: wrap; }
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; }
207 +.specs div { padding: 14px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
208 +.specs b { display: block; font-family: var(--mono); font-size: 1.25rem; font-weight: 600; letter-spacing: -.02em; }
209 +.specs span { display: block; color: var(--ink-3); font-size: .74rem; margin-top: 3px; text-transform: uppercase; letter-spacing: .06em; }
210 +@media (min-width: 640px) { .specs { grid-template-columns: repeat(3, 1fr); } }
211 +@media (min-width: 980px) { .specs { grid-template-columns: repeat(6, 1fr); } .specs div { border-bottom: 0; } }
212 +.detail-grid { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 10px; }
213 +@media (min-width: 980px) { .detail-grid { grid-template-columns: 2fr 1fr; } }
214 +.chart-wrap { margin-top: 8px; }
215 +.chart-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: .84rem; color: var(--ink-2); margin-bottom: 6px; }
216 +.chart-head b { color: var(--ink); font-family: var(--mono); font-weight: 500; }
217 +.chart { width: 100%; height: 150px; display: block; touch-action: pan-y; }
218 +.chart .grid-l { stroke: var(--line); stroke-width: 1; }
219 +.chart .line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
220 +.chart .area { fill: url(#areaGrad); }
221 +.chart .axis { font-family: var(--mono); font-size: 10px; fill: var(--ink-3); }
222 +.chart .cross { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 3; opacity: 0; }
223 +.chart .pt { fill: var(--accent); stroke: var(--bg); stroke-width: 2; r: 4; opacity: 0; }
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); }
225 +.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
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); }
227 +.seg button.active { background: var(--surface-3); color: var(--ink); }
228 +.kv { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; font-size: .88rem; }
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; }
230 +.applist { display: flex; flex-direction: column; gap: 6px; }
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); }
232 +.applist a:hover { border-color: var(--line-2); }
233 +.applist .nm { font-weight: 600; font-size: .92rem; }
234 +.applist .dm { color: var(--ink-3); font-size: .76rem; font-family: var(--mono); margin-left: auto; text-align: right; }
235 +.prose { color: var(--ink-2); font-size: .95rem; }
236 +.prose strong { color: var(--ink); }
237 +.pager { display: flex; justify-content: space-between; gap: 10px; margin-top: 28px; }
238 +.pager a { flex: 1; padding: 14px; border: 1px solid var(--line); border-radius: var(--r); background: var(--surface); }
239 +.pager a:hover { border-color: var(--line-2); }
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 256 .app-domain.none { color: var(--ink-3); font-family: inherit; }
189 −.app-desc { color: var(--ink-2); font-size: .88rem; margin-top: 8px; }
190 −.app-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; align-items: center; }
191 −.app-meta .badge.nodetag { font-family: "SF Mono", ui-monospace, Menlo, monospace; }
192 −.app-meta .ms { color: var(--ink-3); font-size: .74rem; font-variant-numeric: tabular-nums; }
193 −
194 −/* tunnel */
195 −.gateways { display: grid; grid-template-columns: 1fr; gap: 12px; }
196 −@media (min-width: 900px) { .gateways { grid-template-columns: repeat(2, 1fr); } }
197 −.gw { padding: 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); }
198 −.gw.primary { border-color: rgba(45,212,191,.4); background: linear-gradient(180deg, rgba(45,212,191,.08), var(--surface)); }
199 −.gw-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
200 −.gw-head h3 { font-size: 1.2rem; font-family: "SF Mono", ui-monospace, Menlo, monospace; }
201 −.gw-meta { color: var(--ink-2); font-size: .9rem; margin-top: 6px; }
202 −.gw-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 14px 0; }
203 −.peers { display: flex; flex-wrap: wrap; gap: 6px; }
204 −.peer { font-size: .74rem; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--line); color: var(--ink-2); font-family: "SF Mono", ui-monospace, Menlo, monospace; display: inline-flex; align-items: center; gap: 6px; }
205 −.peer::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--off); }
206 −.peer.on::before { background: var(--good); }
207 −.peers-title { font-size: .78rem; color: var(--ink-3); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }
208 −
209 −/* stack */
210 −.stack { display: grid; grid-template-columns: 1fr; gap: 10px; }
211 −@media (min-width: 640px) { .stack { grid-template-columns: repeat(2, 1fr); } }
212 −@media (min-width: 1000px) { .stack { grid-template-columns: repeat(3, 1fr); } }
213 −.tool { padding: 16px 18px; border-radius: var(--r); border: 1px solid var(--line); background: var(--surface); }
214 −.tool h4 { font-size: 1rem; margin-bottom: 6px; }
215 −.tool p { color: var(--ink-2); font-size: .9rem; }
216 −
217 −/* footer */
218 −.foot { max-width: var(--maxw); margin: clamp(60px, 10vw, 120px) auto 0; padding: 28px var(--pad) calc(36px + env(safe-area-inset-bottom)); border-top: 1px solid var(--line); color: var(--ink-3); font-size: .85rem; display: flex; flex-direction: column; gap: 12px; }
219 −.foot strong { color: var(--ink); }
220 −.foot-links { display: flex; flex-wrap: wrap; gap: 8px 18px; }
221 −.foot-links a { color: var(--ink-2); }
222 −.foot-links a:hover { color: var(--ink); }
223 −
224 −.reveal { opacity: 0; transform: translateY(14px); transition: opacity .6s ease, transform .6s ease; }
225 −.reveal.in { opacity: 1; transform: none; }
226 −@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } }
257 +.app-desc { color: var(--ink-2); font-size: .88rem; }
258 +.app-meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 2px; }
259 +.app-meta .ms { color: var(--ink-3); font-size: .74rem; font-family: var(--mono); }
260 +
261 +/* ---------- map ---------- */
262 +.map-layout { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 18px; }
263 +@media (min-width: 980px) { .map-layout { grid-template-columns: 1fr 340px; } }
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); }
265 +.map-box .maplibregl-map { position: absolute; inset: 0; font-family: var(--sans); }
266 +.map-box .maplibregl-ctrl-attrib { background: rgba(9,9,11,.7) !important; color: var(--ink-3) !important; font-size: 10px; }
267 +.map-box .maplibregl-ctrl-attrib a { color: var(--ink-2) !important; }
268 +.map-box .maplibregl-ctrl-group { background: var(--surface) !important; border: 1px solid var(--line-2) !important; box-shadow: none !important; }
269 +.map-box .maplibregl-ctrl-group button { background: transparent !important; } .map-box .maplibregl-ctrl-group button + button { border-top: 1px solid var(--line) !important; }
270 +.map-box .maplibregl-ctrl-group button .maplibregl-ctrl-icon { filter: invert(1) opacity(.8); }
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); }
272 +.map-box .maplibregl-popup-tip { border-top-color: var(--surface-3) !important; border-bottom-color: var(--surface-3) !important; }
273 +.map-box .maplibregl-popup-close-button { color: var(--ink-2); font-size: 16px; padding: 2px 6px; }
274 +.map-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--ink-3); font-size: .9rem; text-align: center; padding: 20px; }
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; }
276 +.pin.big { width: 20px; height: 20px; }
277 +.pin.ovh { background: #a78bfa; box-shadow: 0 0 0 4px rgba(167,139,250,.25); }
278 +.pin.rented { background: #34d399; box-shadow: 0 0 0 4px rgba(52,211,153,.25); }
279 +.map-box.z-far .pin:not(.big) .lbl { opacity: 0; } .map-box.z-far .pin:not(.big):hover .lbl { opacity: 1; }
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; }
281 +.pin.big .lbl { left: 26px; top: 1px; }
282 +.site-list { display: flex; flex-direction: column; gap: 6px; max-height: 560px; overflow: auto; }
283 +.site-row { padding: 12px 14px; cursor: pointer; display: flex; flex-direction: column; gap: 4px; text-align: left; font: inherit; color: inherit; }
284 +.site-row.active { border-color: var(--accent); }
285 +.site-row .t { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-weight: 600; }
286 +.site-row .t .k { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex: none; margin-right: 8px; }
287 +.site-row .t .k.ovh { background: #a78bfa; } .site-row .t .k.rented { background: #34d399; }
288 +.site-row .t .cnt { font-family: var(--mono); font-size: .78rem; color: var(--ink-2); font-weight: 500; }
289 +.site-row .r { color: var(--ink-3); font-size: .8rem; }
290 +.site-row .m { color: var(--ink-2); font-size: .78rem; font-family: var(--mono); }
291 +.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: .78rem; color: var(--ink-2); margin-top: 10px; }
292 +.legend span { display: inline-flex; align-items: center; gap: 6px; }
293 +.legend i { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); } .legend i.ovh { background: #a78bfa; } .legend i.rented { background: #34d399; }
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
@@ -1,5 +1,6 @@
1 −// maclustr-www — site vitrine www.maclustr.io
2 −// Serveur Node sans dépendance : fichiers statiques + /api/live (proxy filtré vers maclustr-agentd).
1 +// maclustr-www — site vitrine www.maclustr.io (v2, multi-pages)
2 +// Serveur Node sans dépendance : fichiers statiques, shell SPA (index.html) pour les routes,
3 +// /api/live et /api/history = proxys filtrés vers maclustr-agentd (jamais d'IP, d'utilisateur ni de jeton exposés).
3 4 // Usage : node server.mjs [port] (env : AGENTD_URL, AGENTD_TOKEN, LIVE_TTL_S)
4 5
5 6 import http from "node:http";
@@ -10,19 +11,17 @@ import { fileURLToPath } from "node:url";
10 11 const __dirname = path.dirname(fileURLToPath(import.meta.url));
11 12 const PORT = Number(process.argv[2] || process.env.PORT || 8280);
12 13 const PUBLIC = path.join(__dirname, "public");
13 −const AGENTD_URL = (process.env.AGENTD_URL || "http://192.168.2.83:9210").replace(/\/$/, "");
14 +const AGENTD_URL = (process.env.AGENTD_URL || "http://127.0.0.1:9210").replace(/\/$/, "");
14 15 const AGENTD_TOKEN = process.env.AGENTD_TOKEN || "";
15 16 const TTL_MS = Number(process.env.LIVE_TTL_S || 30) * 1000;
16 17
17 18 const MIME = {
18 19 ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
19 20 ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
20 − ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2",
21 + ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8", ".woff2": "font/woff2", ".xml": "application/xml",
21 22 };
22 23
23 −// ---------- live cache ----------
24 −let cache = { ts: 0, body: null, inflight: null };
25 −
24 +// ---------- agentd ----------
26 25 async function agentd(pathname) {
27 26 const ctl = new AbortController();
28 27 const t = setTimeout(() => ctl.abort(), 12000);
@@ -32,8 +31,8 @@ async function agentd(pathname) {
32 31 return await r.json();
33 32 } finally { clearTimeout(t); }
34 33 }
34 +const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null);
35 35
36 −// Ne laisse passer que ce qui est public : pas d'IP, pas d'utilisateur, pas de chemin.
37 36 function shape(cluster, apps, tunnel) {
38 37 const nodes = {};
39 38 const metrics = cluster?.metrics || {};
@@ -43,15 +42,19 @@ function shape(cluster, apps, tunnel) {
43 42 nodes[n.name] = {
44 43 online,
45 44 cpu: online ? round(m.cpu, 1) : null,
45 + gpu: online ? round(m.gpu, 0) : null,
46 46 load1: online && Array.isArray(m.load) ? round(m.load[0], 2) : null,
47 47 memUsedGB: online ? round((m.memUsedMB || 0) / 1024, 1) : null,
48 48 memTotalGB: online ? round((m.memTotalMB || n.memoryMB || 0) / 1024, 0) : null,
49 + swapGB: online ? round((m.swapUsedMB || 0) / 1024, 1) : null,
49 50 diskUsedGB: online ? round(m.diskUsedGB, 0) : null,
50 51 diskTotalGB: online ? round(m.diskTotalGB, 0) : null,
52 + netInKBs: online ? round(m.netInKBs, 0) : null,
53 + netOutKBs: online ? round(m.netOutKBs, 0) : null,
54 + procs: online ? m.procs : null,
51 55 uptimeS: online ? m.uptime : null,
52 56 os: online ? m.os : null,
53 57 thermal: online ? m.thermal : null,
54 − gpu: online ? round(m.gpu, 0) : null,
55 58 apps: cluster?.appsByNode?.[n.name] || null,
56 59 };
57 60 }
@@ -60,7 +63,8 @@ function shape(cluster, apps, tunnel) {
60 63 for (const a of list) {
61 64 appOut[a.app] = {
62 65 node: a.node, state: a.state, publicOk: a.public?.ok ?? null, publicMs: a.public?.ms ?? null,
63 − localOk: a.local?.ok ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null,
66 + localOk: a.local?.ok ?? null, localMs: a.local?.ms ?? null, uptime24h: a.uptime24h ?? null, via: a.tunnel?.via || null,
67 + memMB: a.memMB ?? null, cpu: a.cpu ?? null,
64 68 };
65 69 }
66 70 const gws = (tunnel?.gateways || []).map(g => ({
@@ -68,39 +72,70 @@ function shape(cluster, apps, tunnel) {
68 72 peers: (g.wg?.peers || []).map(p => ({ alias: p.alias, online: !!p.online, handshakeS: p.handshakeS ?? null, rx: p.rxBytes ?? 0, tx: p.txBytes ?? 0 })),
69 73 routes: Array.isArray(g.routes) ? g.routes.filter(r => r.kind === "proxy").length : null,
70 74 }));
71 − const onlineCount = Object.values(nodes).filter(n => n.online).length;
75 + const onlineNodes = (cluster?.nodes || []).filter(n => nodes[n.name]?.online);
76 + const cpuVals = onlineNodes.map(n => nodes[n.name].cpu).filter(v => v != null);
72 77 return {
73 78 ts: Date.now(), agentVersion: cluster?.agentVersion || null,
74 79 summary: {
75 − nodesTotal: Object.keys(nodes).length, nodesOnline: onlineCount,
80 + nodesTotal: Object.keys(nodes).length, nodesOnline: onlineNodes.length,
76 81 apps: apps?.summary || null,
77 − coresOnline: (cluster?.nodes || []).filter(n => nodes[n.name]?.online).reduce((s, n) => s + (n.cpuCores || 0), 0),
78 − ramOnlineGB: (cluster?.nodes || []).filter(n => nodes[n.name]?.online).reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0),
82 + coresOnline: onlineNodes.reduce((s, n) => s + (n.cpuCores || 0), 0),
83 + gpuOnline: onlineNodes.reduce((s, n) => s + (n.gpuCores || 0), 0),
84 + ramOnlineGB: round(onlineNodes.reduce((s, n) => s + (n.memoryMB || 0) / 1024, 0), 0),
79 85 ramUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.memUsedGB || 0), 0), 0),
86 + cpuAvg: cpuVals.length ? round(cpuVals.reduce((a, b) => a + b, 0) / cpuVals.length, 1) : null,
87 + diskUsedGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskUsedGB || 0), 0), 0),
88 + diskTotalGB: round(Object.values(nodes).reduce((s, n) => s + (n.diskTotalGB || 0), 0), 0),
89 + netInKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netInKBs || 0), 0), 0),
90 + netOutKBs: round(Object.values(nodes).reduce((s, n) => s + (n.netOutKBs || 0), 0), 0),
91 + procs: Object.values(nodes).reduce((s, n) => s + (n.procs || 0), 0),
92 + tunnelRx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.rx, 0), 0),
93 + tunnelTx: gws.reduce((s, g) => s + g.peers.reduce((a, p) => a + p.tx, 0), 0),
80 94 },
81 95 nodes, apps: appOut, tunnel: gws,
82 96 };
83 97 }
84 −const round = (v, d) => (typeof v === "number" && isFinite(v) ? Number(v.toFixed(d)) : null);
85 98
86 −async function live() {
87 − if (cache.body && Date.now() - cache.ts < TTL_MS) return cache.body;
88 − if (cache.inflight) return cache.inflight;
89 − cache.inflight = (async () => {
90 − try {
91 − const [cluster, apps, tunnel] = await Promise.all([
92 − agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null),
93 − ]);
94 − cache.body = shape(cluster, apps, tunnel);
95 − cache.ts = Date.now();
96 − return cache.body;
97 − } catch (e) {
98 − if (cache.body) return { ...cache.body, stale: true, error: String(e.message || e) };
99 − return { ts: Date.now(), error: String(e.message || e), nodes: {}, apps: {}, tunnel: [], summary: null };
100 − } finally { cache.inflight = null; }
99 +// cache générique
100 +const caches = new Map();
101 +async function cached(key, ttl, fn) {
102 + const c = caches.get(key) || {};
103 + if (c.body && Date.now() - c.ts < ttl) return c.body;
104 + if (c.inflight) return c.inflight;
105 + c.inflight = (async () => {
106 + try { c.body = await fn(); c.ts = Date.now(); return c.body; }
107 + catch (e) { if (c.body) return { ...c.body, stale: true, error: String(e.message || e) }; throw e; }
108 + finally { c.inflight = null; }
101 109 })();
102 − return cache.inflight;
110 + caches.set(key, c);
111 + return c.inflight;
112 +}
113 +const live = () => cached("live", TTL_MS, async () => {
114 + const [cluster, apps, tunnel] = await Promise.all([agentd("/api/cluster"), agentd("/api/apps"), agentd("/api/tunnel").catch(() => null)]);
115 + return shape(cluster, apps, tunnel);
116 +});
117 +const history = (node, win) => cached(`hist:${node}:${win}`, 60000, async () => {
118 + const d = await agentd(`/api/history?node=${encodeURIComponent(node)}&window=${win}`);
119 + return { node, window: win, points: (d.points || []).map(p => ({ ts: p.ts, cpu: round(p.cpu, 1), mem: round(p.mem, 1), gpu: round(p.gpu, 0), netIn: round(p.netIn, 0), netOut: round(p.netOut, 0) })) };
120 +});
121 +
122 +// ---------- titres par route (SEO minimal) ----------
123 +const ROUTE_TITLES = [
124 + [/^\/$/, "MacLustr — Le cluster Apple Silicon"],
125 + [/^\/nodes\/?$/, "Nœuds — MacLustr"],
126 + [/^\/nodes\/([A-Za-z0-9]+)/, (m) => `${m[1]} — nœud MacLustr`],
127 + [/^\/apps\/?$/, "Applications hébergées — MacLustr"],
128 + [/^\/map\/?$/, "Carte des nœuds — MacLustr"],
129 + [/^\/contact\/?$/, "Contact — MacLustr"],
130 +];
131 +let shellCache = null;
132 +function shell(pathname) {
133 + if (!shellCache) shellCache = fs.readFileSync(path.join(PUBLIC, "index.html"), "utf8");
134 + let title = "MacLustr";
135 + for (const [re, t] of ROUTE_TITLES) { const m = pathname.match(re); if (m) { title = typeof t === "function" ? t(m) : t; break; } }
136 + return shellCache.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);
103 137 }
138 +if (process.env.NODE_ENV !== "production") fs.watch(PUBLIC, () => { shellCache = null; });
104 139
105 140 // ---------- http ----------
106 141 const server = http.createServer(async (req, res) => {
@@ -108,31 +143,47 @@ const server = http.createServer(async (req, res) => {
108 143 res.setHeader("X-Content-Type-Options", "nosniff");
109 144 res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
110 145
111 − if (url.pathname === "/healthz") return send(res, 200, "ok\n", "text/plain");
112 − if (url.pathname === "/api/live") {
113 − const body = await live();
114 − res.setHeader("Cache-Control", "public, max-age=15");
115 − return send(res, body.error && !body.summary ? 503 : 200, JSON.stringify(body), MIME[".json"]);
116 − }
117 −
118 − let p = decodeURIComponent(url.pathname);
119 − if (p === "/") p = "/index.html";
120 − const file = path.normalize(path.join(PUBLIC, p));
121 − if (!file.startsWith(PUBLIC)) return send(res, 403, "forbidden", "text/plain");
122 − fs.stat(file, (err, st) => {
123 − if (err || !st.isFile()) {
124 − return fs.readFile(path.join(PUBLIC, "index.html"), (e2, buf) => e2 ? send(res, 404, "not found", "text/plain") : send(res, 200, buf, MIME[".html"]));
146 + try {
147 + if (url.pathname === "/healthz") return send(res, 200, "ok\n", "text/plain");
148 + if (url.pathname === "/api/live") {
149 + res.setHeader("Cache-Control", "public, max-age=15");
150 + try { return send(res, 200, JSON.stringify(await live()), MIME[".json"]); }
151 + catch (e) { return send(res, 503, JSON.stringify({ ts: Date.now(), error: String(e.message || e), nodes: {}, apps: {}, tunnel: [], summary: null }), MIME[".json"]); }
152 + }
153 + if (url.pathname === "/api/history") {
154 + const node = (url.searchParams.get("node") || "").replace(/[^A-Za-z0-9]/g, "");
155 + const win = ["1h", "6h", "24h"].includes(url.searchParams.get("window")) ? url.searchParams.get("window") : "24h";
156 + if (!node) return send(res, 400, JSON.stringify({ error: "node requis" }), MIME[".json"]);
157 + res.setHeader("Cache-Control", "public, max-age=60");
158 + try { return send(res, 200, JSON.stringify(await history(node, win)), MIME[".json"]); }
159 + catch (e) { return send(res, 503, JSON.stringify({ node, window: win, points: [], error: String(e.message || e) }), MIME[".json"]); }
160 + }
161 + if (url.pathname === "/robots.txt") return send(res, 200, "User-agent: *\nAllow: /\nSitemap: https://www.maclustr.io/sitemap.xml\n", "text/plain");
162 + if (url.pathname === "/sitemap.xml") {
163 + const ids = sitemapIds();
164 + const urls = ["/", "/nodes", "/apps", "/map", "/contact", ...ids.map(i => `/nodes/${i}`)];
165 + return send(res, 200, `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.map(u => ` <url><loc>https://www.maclustr.io${u}</loc></url>`).join("\n")}\n</urlset>\n`, MIME[".xml"]);
125 166 }
126 − const ext = path.extname(file).toLowerCase();
127 − res.setHeader("Cache-Control", ext === ".html" ? "no-cache" : "public, max-age=3600");
128 − res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": st.size });
129 − fs.createReadStream(file).pipe(res);
130 − });
131 −});
132 167
133 −function send(res, code, body, type) {
134 − res.writeHead(code, { "Content-Type": type });
135 − res.end(body);
168 + let p = decodeURIComponent(url.pathname);
169 + const file = path.normalize(path.join(PUBLIC, p));
170 + if (!file.startsWith(PUBLIC)) return send(res, 403, "forbidden", "text/plain");
171 + const isAsset = path.extname(p) !== "";
172 + if (!isAsset) { res.setHeader("Cache-Control", "no-cache"); return send(res, 200, shell(p), MIME[".html"]); }
173 + fs.stat(file, (err, st) => {
174 + if (err || !st.isFile()) return send(res, 404, "not found", "text/plain");
175 + const ext = path.extname(file).toLowerCase();
176 + res.setHeader("Cache-Control", ext === ".html" ? "no-cache" : "public, max-age=600");
177 + res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream", "Content-Length": st.size });
178 + fs.createReadStream(file).pipe(res);
179 + });
180 + } catch (e) {
181 + send(res, 500, "error", "text/plain");
182 + }
183 +});
184 +function sitemapIds() {
185 + try { const s = fs.readFileSync(path.join(PUBLIC, "data.js"), "utf8"); return [...s.matchAll(/\{ id:"([A-Za-z0-9]+)", group:"(?:lan|rented|ovh)"/g)].map(m => m[1]); } catch { return []; }
136 186 }
187 +function send(res, code, body, type) { res.writeHead(code, { "Content-Type": type }); res.end(body); }
137 188
138 −server.listen(PORT, "0.0.0.0", () => console.log(`maclustr-www :${PORT} → agentd ${AGENTD_URL} (token ${AGENTD_TOKEN ? "ok" : "ABSENT"})`));
189 +server.listen(PORT, "0.0.0.0", () => console.log(`maclustr-www v2 :${PORT} → agentd ${AGENTD_URL} (token ${AGENTD_TOKEN ? "ok" : "ABSENT"})`));
139 190