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>
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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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 | |