spb/maclustr-www
Public
JavaScript 58%
CSS 24.8%
HTML 17.1%
1/* MacLustr v2 — routeur multi-pages + superposition live (/api/live toutes les 30 s) */2(function () {3 const D = window.MACLUSTR;4 const $ = (s, r = document) => r.querySelector(s);5 const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));6 const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));7 const fmt = n => new Intl.NumberFormat("fr-CA").format(n);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]));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));1617 let live = null; // dernier /api/live18 let page = null; // { name, patch(), destroy() }19 const totals = (() => {20 const macs = D.nodes.filter(n => n.group !== "ovh");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") : "—";31 const pct = (a, b) => (a != null && b) ? Math.min(100, Math.round(a / b * 100)) : null;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; };3940 // ---------- 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;52 const bar = m.querySelector("i"), v = m.querySelector(".v");53 if (p == null) { m.classList.add("na"); bar.style.width = "0"; v.textContent = "—"; return; }54 m.classList.remove("na"); bar.style.width = p + "%"; bar.className = p >= 90 ? "crit" : p >= 75 ? "hi" : ""; v.textContent = label;55 }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`);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 };7172 // tooltip global73 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"); }7677 // ---------- pages ----------78 const pages = {};7980 // ===== 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>8990 <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>100101 <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>125126 <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>130131 <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>141142 <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>`;146147 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);151 });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>`;177 }178179 // ===== 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;230231 // ===== 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>260261 <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>`;309310 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; });332 if (!live) return;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; };356357 // graphe SVG : ligne 2 px, aire douce, 3 lignes de grille, min/max, crosshair + infobulle358 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);390 }391392 // ===== 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 // ancre418 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 };421422 // ===== 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>435 </div>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>`;442443 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" }); }));455456 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."; });482483 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 approximatif488 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;495 }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);503 });504 return mapLibP;505 }506507 // ===== 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 };541542 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 };546547 // ---------- 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)); });576577 // ---------- live ----------578 async function refresh() {579 const pill = $("#livePill");580 try {581 const r = await fetch("/api/live", { cache: "no-store" }); const j = await r.json();582 if (!j.summary) throw new Error(j.error || "sans données");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 || ""}`;586 } catch (e) {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 };589 }590 page?.patch?.();591 }592 route(); refresh(); setInterval(refresh, 30000);593 document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });594})();595