SPB Git forge

spb/maclustr-www

Public
1commits 1branches 0releases
60.0 KBsize
maindefault branch
13 days agolast push
JavaScript 58% CSS 24.8% HTML 17.1%
50.0 KB · 595 lines javascript
Raw Blame History
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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