/* MacLustr v2 — routeur multi-pages + superposition live (/api/live toutes les 30 s) */ (function () { const D = window.MACLUSTR; const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const fmt = n => new Intl.NumberFormat("fr-CA").format(n); const fmt1 = n => new Intl.NumberFormat("fr-CA", { maximumFractionDigits: 1 }).format(n); const GROUP_LABEL = { lan: "LAN Saint-Augustin", rented: "Mac dédié loué", ovh: "OVHcloud · Linux" }; const GROUP_SHORT = { lan: "LAN", rented: "Loué", ovh: "OVH" }; const view = $("#view"); const byId = Object.fromEntries(D.nodes.map(n => [n.id, n])); const appById = Object.fromEntries(D.apps.map(a => [a.id, a])); const appsByNode = {}; D.apps.forEach(a => (appsByNode[a.node] ||= []).push(a)); const nodesBySite = {}; D.nodes.forEach(n => (nodesBySite[n.site] ||= []).push(n)); let live = null; // dernier /api/live let page = null; // { name, patch(), destroy() } const totals = (() => { const macs = D.nodes.filter(n => n.group !== "ovh"); return { nodes: D.nodes.length, macs: macs.length, ovh: D.nodes.length - macs.length, cores: D.nodes.reduce((s, n) => s + n.cores, 0), gpu: macs.reduce((s, n) => s + (n.gpu || 0), 0), ram: D.nodes.reduce((s, n) => s + n.ram, 0), disk: D.nodes.reduce((s, n) => s + (n.disk || 0), 0), apps: D.apps.length, sites: D.apps.filter(a => a.domain).length, places: Object.keys(D.sites).length, countries: new Set(Object.values(D.sites).map(s => s.region.split(",").pop().trim())).size, }; })(); const ramFmt = gb => gb >= 1000 ? fmt1(gb / 1024) + " To" : gb + " Go"; const diskFmt = gb => gb ? (gb >= 1000 ? fmt1(gb / 1000) + " To" : gb + " Go") : "—"; const pct = (a, b) => (a != null && b) ? Math.min(100, Math.round(a / b * 100)) : null; 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`; }; const bytes = b => b >= 1e12 ? fmt1(b / 1e12) + " To" : b >= 1e9 ? fmt1(b / 1e9) + " Go" : fmt(Math.round(b / 1e6)) + " Mo"; const kbs = k => k == null ? "—" : k >= 1024 ? fmt1(k / 1024) + " Mo/s" : fmt(k) + " ko/s"; const liveNode = id => live?.nodes?.[id] || null; const liveApp = id => live?.apps?.[id] || null; const gwOf = id => live?.tunnel?.find(g => g.name === id) || null; 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; }; // ---------- helpers UI ---------- const statusOf = n => { 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"]; } if (!live || live.dead) return ["na", live?.dead ? "inconnu" : "…"]; const l = liveNode(n.id); if (!l || !l.online) return ["off", "hors ligne"]; if (l.cpu >= 90 || pct(l.memUsedGB, l.memTotalGB) >= 92) return ["warn", "chargé"]; return ["on", "en ligne"]; }; const statusHTML = (n) => { const [c, t] = statusOf(n); return `${t}`; }; const meterHTML = (key, label) => `
${label}
—
`; function setMeter(root, key, p, label) { const m = root.querySelector(`[data-m="${key}"]`); if (!m) return; const bar = m.querySelector("i"), v = m.querySelector(".v"); if (p == null) { m.classList.add("na"); bar.style.width = "0"; v.textContent = "—"; return; } m.classList.remove("na"); bar.style.width = p + "%"; bar.className = p >= 90 ? "crit" : p >= 75 ? "hi" : ""; v.textContent = label; } function patchMeters(root, id) { const l = liveNode(id); if (!l || !l.online) { ["cpu", "ram", "disk"].forEach(k => setMeter(root, k, null)); return; } setMeter(root, "cpu", Math.round(l.cpu ?? 0), `${Math.round(l.cpu ?? 0)} %`); setMeter(root, "ram", pct(l.memUsedGB, l.memTotalGB), `${fmt1(l.memUsedGB)} / ${l.memTotalGB} Go`); setMeter(root, "disk", pct(l.diskUsedGB, l.diskTotalGB), `${fmt(l.diskUsedGB)} / ${fmt(l.diskTotalGB)} Go`); } function patchStatuses(root) { $$("[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; }); } const chipClass = t => ({ ultra: "accent", max: "accent", pro: "", base: "", epyc: "accent", ryzen: "accent" }[t] || ""); const appStatus = a => { const l = liveApp(a.id); if (!live || live.dead) return ["na", live?.dead ? "inconnu" : "…"]; if (!l) return ["na", "inconnu"]; return l.state === "up" ? ["on", "en ligne"] : l.state === "degraded" ? ["warn", "dégradée"] : l.state === "down" ? ["bad", "hors service"] : ["off", l.state || "—"]; }; // tooltip global const tip = document.createElement("div"); tip.className = "tip"; document.body.appendChild(tip); 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"; } function hideTip() { tip.classList.remove("show"); } // ---------- pages ---------- const pages = {}; // ===== Accueil ===== pages.home = () => { view.innerHTML = `

Cluster privé · Apple Silicon · Québec

Un supercalculateur
fait de Macs.

MacLustr réunit ${totals.macs} Macs Apple Silicon et ${totals.ovh} serveurs OVHcloud, répartis dans ${totals.places} emplacements sur trois continents, en une seule plateforme : orchestration automatique, tunnel chiffré, supervision continue. Elle héberge ${totals.apps} applications en production.

Explorer les nœudsVoir la carte
${totals.nodes}nœuds
${fmt(totals.cores)}cœurs CPU
${fmt(totals.gpu)}cœurs GPU
${ramFmt(totals.ram)}mémoire
${totals.apps}applications
${totals.sites}sites HTTPS

En direct

État du cluster

Agrégats calculés sur les ${totals.macs} Macs à partir de maclustr-agentd, rafraîchis toutes les 30 secondes.

Charge par nœud CPU · ${totals.macs} Macs

${D.nodes.filter(n => n.group !== "ovh").map(n => `${esc(n.id)}`).join("")}
0 %100 % CPUhachuré = hors ligne

Totaux live

—Macs en ligne
—CPU moyen
—apps saines
—cœurs actifs
—processus
—trafic entrant
Mémoire utilisée—
Disque utilisé—
Tunnel · trafic cumulé vers les passerelles—

Emplacements

${totals.places} sites, ${totals.countries} pays

Le LAN de Saint-Augustin, deux centres OVHcloud et quatre hébergeurs de Macs dédiés.

Carte mondiale →
${Object.entries(D.sites).map(([k, s]) => siteCard(k, s)).join("")}

Architecture

Du portable au monde, en cinq étapes

Une commande, mld deploy, choisit le nœud, synchronise, démarre, publie la route et vérifie la santé. Rien n'est placé à la main.

01

Poste de travail

Le code est mis en scène vers la passerelle. Claude Code pilote la plupart des opérations.

02

Passerelle M1M32

Forge git privée (spbgit) et orchestrateur mld : scan live, score, rsync, PM2, registre, auto-réparation toutes les 5 minutes.

03

${totals.macs} nœuds Mac

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.

04

WireGuard

Chaque nœud tient deux tunnels chiffrés permanents vers les passerelles. Aucun port ouvert sur le LAN.

05

Passerelles OVHcloud

Caddy termine le TLS à Beauharnois (≈ 10 ms) ou Gravelines et route chaque domaine vers son nœud.

Hébergement

${totals.apps} applications en production

Groupe Ka, données de marché, index scientifiques, outils d'enseignement, services de la plateforme.

Toutes les applications →
${D.appGroups.map(g => { const apps = D.apps.filter(a => a.group === g.id); return `

${esc(g.name)}

${apps.length}

${esc(g.blurb)}

${apps.slice(0, 6).map(a => `${esc(a.name)}`).join("")}${apps.length > 6 ? `+${apps.length - 6}` : ""}
`; }).join("")}
`; const cells = Object.fromEntries($$("[data-cell]").map(c => [c.dataset.cell, c])); Object.values(cells).forEach(c => { c.addEventListener("mousemove", e => { const l = liveNode(c.dataset.cell); const n = byId[c.dataset.cell]; showTip(`${esc(n.id)} · ${esc(n.chip)}
État${l?.online ? "en ligne" : "hors ligne"}
${l?.online ? `
CPU${Math.round(l.cpu)} %
RAM${fmt1(l.memUsedGB)} / ${l.memTotalGB} Go
Charge${l.load1}
` : ""}`, e.clientX, e.clientY); }); c.addEventListener("mouseleave", hideTip); }); const set = (k, v) => { const e = $(`[data-s="${k}"]`); if (e) e.textContent = v; }; const setF = (k, v) => { const e = $(`[data-f="${k}"]`); if (e) e.textContent = v; }; const patch = () => { patchStatuses(view); $$(".site-card .nodes span").forEach(s => { const l = liveNode(s.dataset.n); s.classList.toggle("on", !!l?.online || byId[s.dataset.n]?.group === "ovh"); }); if (!live?.summary) return; const s = live.summary; $("#liveTs").textContent = new Date(live.ts).toLocaleTimeString("fr-CA") + (live.stale ? " · cache" : ""); $("#liveAgent").textContent = live.agentVersion ? `agentd ${live.agentVersion}` : ""; set("online", `${s.nodesOnline}/${s.nodesTotal}`); set("cpu", s.cpuAvg != null ? `${fmt1(s.cpuAvg)} %` : "—"); set("apps", s.apps ? `${s.apps.up}/${s.apps.total}` : "—"); set("cores", fmt(s.coresOnline)); set("procs", fmt(s.procs)); set("net", kbs(s.netInKBs)); set("ramlbl", `${fmt(s.ramUsedGB)} / ${fmt(s.ramOnlineGB)} Go`); $(`[data-s="rambar"]`).style.width = pct(s.ramUsedGB, s.ramOnlineGB) + "%"; set("disklbl", `${fmt(s.diskUsedGB)} / ${fmt(s.diskTotalGB)} Go`); $(`[data-s="diskbar"]`).style.width = pct(s.diskUsedGB, s.diskTotalGB) + "%"; set("tunnel", `${bytes(s.tunnelRx)} ↑ · ${bytes(s.tunnelTx)} ↓`); 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`); 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`); 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; }); }; patch(); return { patch }; }; function siteCard(k, s) { const ns = nodesBySite[k] || []; return `
${esc(s.name)}${ns.length} nœud${ns.length > 1 ? "s" : ""}
${esc(s.region)} · ${esc(s.operator)}
${ns.map(n => `${esc(n.id)}`).join("")}
`; } // ===== Nœuds (liste) ===== pages.nodes = () => { let filter = "all", sortKey = "cores", q = ""; view.innerHTML = `

Inventaire

${totals.nodes} nœuds, un par un.

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.

${[["all", "Tous", D.nodes.length], ["lan", "LAN", nodesCount("lan")], ["rented", "Macs loués", nodesCount("rented")], ["ovh", "OVHcloud", nodesCount("ovh")]].map(([k, l, n]) => ``).join("")}
NœudPuceRessourcesLiveÉtat
`; const rows = $("#rows"); const list = () => { let l = D.nodes.filter(n => filter === "all" || n.group === filter); 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)); } 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]; return l.sort(cmp); }; const render = () => { const l = list(); rows.innerHTML = l.length ? l.map(n => `
${esc(n.id)}
${esc(n.model)} · ${GROUP_SHORT[n.group]}
${esc(n.chip)}
${n.cores} ${n.threads ? "thr" : "c"}${n.gpu ? `${n.gpu} GPU` : ""}${n.ram} Go${diskFmt(n.disk)}
${n.group !== "ovh" ? `
${meterHTML("cpu", "CPU")}${meterHTML("ram", "RAM")}
` : `
${esc(n.role)}
`}
${statusHTML(n)}
→
${esc(D.sites[n.site]?.name || "")} · ${esc(n.role)}
`).join("") : `
Aucun nœud ne correspond.
`; 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); const on = l.filter(n => n.group === "ovh" || liveNode(n.id)?.online).length; $("#gsum").innerHTML = `${l.length} nœuds${fmt(cores)} ${filter === "ovh" ? "threads" : "cœurs"}${gpu ? `${fmt(gpu)} cœurs GPU` : ""}${ramFmt(ram)} RAM${live ? `${on} en ligne` : ""}`; patch(); }; const patch = () => { patchStatuses(rows); $$("[data-row]", rows).forEach(r => patchMeters(r, r.dataset.row)); if (sortKey === "load" && live) { /* re-tri à la prochaine interaction */ } }; $$(".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(); })); $("#sortSel").addEventListener("change", e => { sortKey = e.target.value; render(); }); $("#q").addEventListener("input", e => { q = e.target.value.trim(); render(); }); render(); return { patch: () => { if (sortKey === "load") render(); else patch(); } }; }; const nodesCount = g => D.nodes.filter(n => n.group === g).length; // ===== Fiche nœud ===== pages.node = (id) => { const n = byId[id] || byId[Object.keys(byId).find(k => k.toLowerCase() === String(id).toLowerCase())]; if (!n) return pages.notFound(`Nœud « ${esc(id)} » inconnu.`); const site = D.sites[n.site]; const apps = appsByNode[n.id] || []; 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]; const isMac = n.group !== "ovh"; let win = "24h"; view.innerHTML = `
Accueil/Nœuds/${esc(n.id)}

${esc(n.id)}

${statusHTML(n)}

${esc(n.model)} · ${esc(n.chip)} · ${esc(n.role)}

${esc(n.chip)}${GROUP_LABEL[n.group]}${n.provider ? `${esc(n.provider)}` : ""}${esc(site?.name || "")}, ${esc(site?.region || "")}${n.tags.filter(t => !["worker", "distant", "linux"].includes(t)).map(t => `${esc(t)}`).join("")}
${n.cores}${n.threads ? "threads" : "cœurs CPU"}
${n.gpu || "—"}cœurs GPU
${n.ram} Gomémoire
${diskFmt(n.disk)}stockage
—${isMac ? "macOS" : "système"}
—uptime
${isMac ? `

Utilisation en direct

${meterHTML("cpu", "CPU")}${meterHTML("ram", "RAM")}${meterHTML("disk", "Disque")}
Charge (1 min)
—
Processus
—
Réseau
—
Swap
—
Thermique
—

Historique

CPU
Mémoire utilisée
Réseau entrant
` : `

Passerelle

—routes HTTPS
—pairs WireGuard
—trafic reçu
`}

Rôle

${esc(n.role)}. ${esc(n.desc)}

Applications hébergées ${apps.length}

${apps.length ? `` : `

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." : "."}

`}

Réseau

${n.ip ? `
IP LAN
${esc(n.ip)}
` : ""}
Emplacement
${esc(site?.name || "")}, ${esc(site?.region || "")}
Opérateur
${esc(site?.operator || n.provider || "")}
${isMac ? `
Tunnel
—
` : `
Rôle tunnel
${n.tags.includes("gateway") ? "hub WireGuard + Caddy" : "hors tunnel"}
`}
Supervision
${isMac ? "maclustr-agentd · PM2" : "SSH direct"}

Même emplacement

${(nodesBySite[n.site] || []).filter(x => x.id !== n.id).map(x => `${esc(x.id)}`).join("") || "Seul nœud ici."}
`; const set = (k, v) => { const e = $(`[data-d="${k}"]`); if (e) e.textContent = v; }; let hist = null; async function loadHist() { if (!isMac) return; try { const r = await fetch(`/api/history?node=${encodeURIComponent(n.id)}&window=${win}`); hist = await r.json(); } catch { hist = { points: [] }; } drawCharts(); } function drawCharts() { if (!hist || !$("#chartCpu")) return; const pts = hist.points || []; chart($("#chartCpu"), pts, "cpu", { unit: "%", max: 100 }); chart($("#chartMem"), pts, "mem", { unit: " Go", max: n.ram }); chart($("#chartNet"), pts, "netIn", { unit: "", fmt: kbs }); const last = pts[pts.length - 1]; $("#cpuNow").textContent = last ? `${fmt1(last.cpu)} % · moy. ${fmt1(avg(pts, "cpu"))} %` : ""; $("#memNow").textContent = last ? `${fmt1(last.mem)} Go · max ${fmt1(Math.max(...pts.map(p => p.mem || 0)))} Go` : ""; $("#netNow").textContent = last ? `${kbs(last.netIn)} · max ${kbs(Math.max(...pts.map(p => p.netIn || 0)))}` : ""; } $$("#winSeg button").forEach(b => b.addEventListener("click", () => { $$("#winSeg button").forEach(x => x.classList.remove("active")); b.classList.add("active"); win = b.dataset.w; loadHist(); })); const patch = () => { patchStatuses(view); $$("[data-appst]").forEach(el => { const [c, t] = appStatus(appById[el.dataset.appst]); el.className = `status ${c}`; el.textContent = t; }); if (!live) return; set("ts", new Date(live.ts).toLocaleTimeString("fr-CA")); if (isMac) { const l = liveNode(n.id); patchMeters(view, n.id); if (l?.online) { set("os", l.os ? `${l.os}` : "—"); set("uptime", uptime(l.uptimeS)); set("load", `${l.load1}`); set("procs", fmt(l.procs || 0)); set("net", `${kbs(l.netInKBs)} ↓ · ${kbs(l.netOutKBs)} ↑`); set("swap", `${fmt1(l.swapGB || 0)} Go`); set("thermal", l.thermal || "—"); } else { ["os", "uptime", "load", "procs", "net", "swap", "thermal"].forEach(k => set(k, "—")); } const p = peerOf(n.id); set("peer", p ? `${p.online ? "raccordé" : "handshake ancien"} · ${p.gw} · ${bytes(p.rx)} ↑` : "non raccordé"); } else { set("os", "Ubuntu 24.04"); const g = gwOf(n.id); if (g) { 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))); $("#peerList").innerHTML = g.peers.sort((a, b) => a.alias.localeCompare(b.alias)).map(p => `${esc(p.alias)}`).join(""); set("uptime", "—"); } else { set("routes", "—"); set("peers", "—"); set("traffic", "—"); set("uptime", "—"); if ($("#peerList")) $("#peerList").innerHTML = `${n.tags.includes("gateway") ? "état non remonté" : "Serveur de réserve : aucun service public pour l'instant."}`; } } }; patch(); loadHist(); return { patch, destroy() {} }; }; 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; }; // graphe SVG : ligne 2 px, aire douce, 3 lignes de grille, min/max, crosshair + infobulle function chart(host, pts, key, opt) { const data = pts.filter(p => p[key] != null); if (data.length < 2) { host.innerHTML = `
Pas encore d'historique pour cette fenêtre.
`; return; } const W = 600, H = 150, padL = 52, padR = 8, padT = 8, padB = 20; const xs = data.map(p => p.ts), ys = data.map(p => p[key]); const x0 = xs[0], x1 = xs[xs.length - 1]; const yMax = opt.max || Math.max(1, Math.max(...ys) * 1.15); const X = t => padL + (t - x0) / Math.max(1, x1 - x0) * (W - padL - padR); const Y = v => padT + (1 - Math.min(v, yMax) / yMax) * (H - padT - padB); const path = data.map((p, i) => `${i ? "L" : "M"}${X(p.ts).toFixed(1)},${Y(p[key]).toFixed(1)}`).join(""); const area = `${path}L${X(x1).toFixed(1)},${(H - padB).toFixed(1)}L${X(x0).toFixed(1)},${(H - padB).toFixed(1)}Z`; const ticks = [0, 0.5, 1].map(f => yMax * f); const tLbl = t => new Date(t * 1000).toLocaleTimeString("fr-CA", { hour: "2-digit", minute: "2-digit" }); const fmtV = opt.fmt ? opt.fmt : (v => (opt.unit === "%" ? Math.round(v) : fmt1(v)) + opt.unit); host.innerHTML = ` ${ticks.map(v => `${fmtV(v)}`).join("")} ${tLbl(x0)}${tLbl(x1)} `; const svg = host.querySelector("svg"), cross = svg.querySelector(".cross"), dot = svg.querySelector(".pt"), hit = svg.querySelector(".hit"); const move = e => { const r = svg.getBoundingClientRect(); const cx = (e.touches ? e.touches[0].clientX : e.clientX); const fx = (cx - r.left) / r.width * W; const t = x0 + (fx - padL) / (W - padL - padR) * (x1 - x0); 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; } } const px = X(best.ts), py = Y(best[key]); cross.setAttribute("x1", px); cross.setAttribute("x2", px); cross.style.opacity = 1; dot.setAttribute("cx", px); dot.setAttribute("cy", py); dot.style.opacity = 1; showTip(`${fmtV(best[key])}
${new Date(best.ts * 1000).toLocaleString("fr-CA", { weekday: "short", hour: "2-digit", minute: "2-digit" })}
`, cx, r.top + py / H * r.height); }; const leave = () => { cross.style.opacity = 0; dot.style.opacity = 0; hideTip(); }; hit.addEventListener("mousemove", move); hit.addEventListener("mouseleave", leave); hit.addEventListener("touchstart", move, { passive: true }); hit.addEventListener("touchmove", move, { passive: true }); hit.addEventListener("touchend", leave); } // ===== Applications ===== pages.apps = () => { view.innerHTML = `

Hébergement

${totals.apps} applications en production.

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.

${D.appGroups.map(g => { const apps = D.apps.filter(a => a.group === g.id); return `

${esc(g.name)}

${apps.length} app${apps.length > 1 ? "s" : ""}

${esc(g.blurb)}

${apps.map(a => ` <${a.domain ? `a href="https://${esc(a.domain)}/" target="_blank" rel="noopener"` : "div"} class="app" id="${a.id}" data-app="${a.id}">
${esc(a.name)}…
${a.domain ? esc(a.domain) : "service interne"}

${esc(a.desc)}

${esc(a.node)}:${a.port}
`).join("")}
`; }).join("")}
`; const patch = () => { $$("[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(" · "); } }); }; patch(); // ancre const h = location.hash.slice(1); if (h) requestAnimationFrame(() => { const el = document.getElementById(h); if (el) { el.scrollIntoView({ block: "start" }); window.scrollBy(0, -70); } }); return { patch }; }; // ===== Carte ===== pages.map = (params) => { view.innerHTML = `

Emplacements

${totals.places} sites, ${totals.countries} pays, un seul cluster.

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.

Chargement de la carte…
${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 ``; }).join("")}
LAN MacLustrOVHcloudMacs loués
${Object.entries(D.sites).map(([k, s]) => { const ns = nodesBySite[k] || []; return `

${esc(s.name)} · ${esc(s.region)}

${s.lat.toFixed(2)}, ${s.lon.toFixed(2)}

${esc(s.note)}

${ns.map(n => `${esc(n.id)}`).join("")}
`; }).join("")}
`; let map = null, markers = {}, destroyed = false, popup = null; const siteOnline = k => { const ns = nodesBySite[k] || []; return ns.filter(n => n.group === "ovh" ? (gwOf(n.id)?.ok ?? true) : liveNode(n.id)?.online).length; }; const popupHTML = (k) => { const s = D.sites[k]; const ns = nodesBySite[k] || []; return `
${esc(s.name)} · ${esc(s.region)}
${esc(s.operator)}
${ns.map(n => `${esc(n.id)}`).join("")}
`; }; const focus = (k, fly = true) => { $$(".site-row").forEach(r => r.classList.toggle("active", r.dataset.site === k)); if (!map || !D.sites[k]) return; const s = D.sites[k]; if (fly) map.flyTo({ center: [s.lon, s.lat], zoom: Math.max(map.getZoom(), 4.2), speed: .9, essential: true }); if (popup) popup.remove(); popup = new maplibregl.Popup({ offset: 14, closeButton: true, maxWidth: "280px" }).setLngLat([s.lon, s.lat]).setHTML(popupHTML(k)).addTo(map); }; $$(".site-row").forEach(r => r.addEventListener("click", () => { focus(r.dataset.site); $("#mapBox").scrollIntoView({ block: "nearest", behavior: "smooth" }); })); loadMapLibre().then(() => { if (destroyed) return; const box = $("#mapBox"); $("#mapFallback")?.remove(); const el = document.createElement("div"); el.style.position = "absolute"; el.style.inset = "0"; box.appendChild(el); 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 }); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); map.on("style.load", () => { try { map.setProjection({ type: "globe" }); } catch { } // liens WireGuard : chaque site → passerelles (Beauharnois, Gravelines) const hubs = ["bhs", "gra"]; const feats = []; 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]) } }); }); }); 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]) } }); map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: feats } }); 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] } }); }); Object.entries(D.sites).forEach(([k, s]) => { const ns = nodesBySite[k] || []; const d = document.createElement("div"); d.className = `pin ${s.kind} ${ns.length >= 8 ? "big" : ""}`; d.innerHTML = `${esc(s.name)} · ${ns.length}`; d.addEventListener("click", (e) => { e.stopPropagation(); focus(k, false); }); markers[k] = new maplibregl.Marker({ element: d, anchor: "center" }).setLngLat([s.lon, s.lat]).addTo(map); }); const zoomCls = () => { box.classList.toggle("z-far", map.getZoom() < 3); }; map.on("zoom", zoomCls); zoomCls(); map.on("load", () => { if (params.site && D.sites[params.site]) setTimeout(() => focus(params.site), 400); }); map.on("error", () => { }); }).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."; }); 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` : "…"; }); }; patch(); return { patch, destroy() { destroyed = true; try { map?.remove(); } catch { } } }; }; function arc(a, b, n = 40) { // grand cercle approximatif const toR = d => d * Math.PI / 180, toD = r => r * 180 / Math.PI; const [lon1, lat1, lon2, lat2] = [toR(a[0]), toR(a[1]), toR(b[0]), toR(b[1])]; 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)); if (!d) return [a, b]; const out = []; 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)))]); } return out; } let mapLibP = null; function loadMapLibre() { if (window.maplibregl) return Promise.resolve(); if (mapLibP) return mapLibP; mapLibP = new Promise((res, rej) => { const css = document.createElement("link"); css.rel = "stylesheet"; css.href = "https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css"; document.head.appendChild(css); 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); }); return mapLibP; } // ===== Contact ===== pages.contact = () => { const c = D.contact; view.innerHTML = `

Contact

Parlons du cluster.

Questions sur MacLustr, sur une application hébergée, sur une collaboration ou un accès API : une seule adresse.

${esc(c.email)}

Ce que vous pouvez demander

  • Une clé ou une limite plus élevée pour une API du cluster (HF Market Data, PDB API, Fetcha…).
  • L'hébergement d'un projet ou d'un service sur MacLustr.
  • Du calcul distribué ponctuel sur les ${totals.cores} cœurs.
  • Une question sur l'architecture, le tunnel ou l'orchestrateur.

Responsable

Nom
${esc(c.owner)}
Courriel
${esc(c.email)}
Site
${esc(c.site.replace("https://", ""))}
Forge
${esc(c.git.replace("https://", ""))}

Où

Saint-Augustin-de-Desmaures, Québec, Canada. Fuseau America/Toronto (UTC−4 / −5).

Voir sur la carte

État du service

…

`; $("#copyMail").addEventListener("click", async () => { try { await navigator.clipboard.writeText(c.email); $("#copied").textContent = "copiée ✓"; setTimeout(() => $("#copied").textContent = "", 2000); } catch { } }); 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."; }; patch(); return { patch }; }; pages.notFound = (msg) => { view.innerHTML = `

404

Page introuvable.

${msg || "Cette adresse ne mène nulle part."}

AccueilNœuds
`; return { patch() { } }; }; // ---------- routeur ---------- function route() { const path = location.pathname.replace(/\/+$/, "") || "/"; const params = Object.fromEntries(new URLSearchParams(location.search)); page?.destroy?.(); hideTip(); let name = "home", out; if (path === "/") out = pages.home(); else if (path === "/nodes") { name = "nodes"; out = pages.nodes(); } else if (path.startsWith("/nodes/")) { name = "nodes"; out = pages.node(decodeURIComponent(path.slice(7))); } else if (path === "/apps") { name = "apps"; out = pages.apps(); } else if (path === "/map") { name = "map"; out = pages.map(params); } else if (path === "/contact") { name = "contact"; out = pages.contact(); } else { name = ""; out = pages.notFound(); } page = out; $$("[data-route]").forEach(a => a.classList.toggle("active", a.dataset.route === name)); 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" }; document.title = titles[name] || "MacLustr"; $("#drawer").classList.remove("open"); $("#burger").setAttribute("aria-expanded", "false"); } document.addEventListener("click", e => { const a = e.target.closest("a[data-link]"); if (!a || e.metaKey || e.ctrlKey || e.shiftKey || a.target === "_blank") return; const href = a.getAttribute("href"); if (!href || href.startsWith("http") || href.startsWith("mailto")) return; e.preventDefault(); if (href.startsWith("#")) { const el = document.getElementById(href.slice(1)); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } return; } if (href === location.pathname + location.search + location.hash) { window.scrollTo({ top: 0, behavior: "smooth" }); return; } history.pushState(null, "", href); route(); window.scrollTo(0, 0); }); window.addEventListener("popstate", route); $("#burger").addEventListener("click", () => { const d = $("#drawer"); const open = d.classList.toggle("open"); $("#burger").setAttribute("aria-expanded", String(open)); }); // ---------- live ---------- async function refresh() { const pill = $("#livePill"); try { const r = await fetch("/api/live", { cache: "no-store" }); const j = await r.json(); if (!j.summary) throw new Error(j.error || "sans données"); live = j; pill.className = "live-pill ok"; pill.querySelector(".txt").textContent = `${j.summary.nodesOnline}/${j.summary.nodesTotal} Macs · ${j.summary.apps ? j.summary.apps.up + "/" + j.summary.apps.total + " apps" : ""}`; $("#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 || ""}`; } catch (e) { pill.className = "live-pill bad"; pill.querySelector(".txt").textContent = "live indisponible"; if (!live) live = { ts: Date.now(), nodes: {}, apps: {}, tunnel: [], summary: null, dead: true }; } page?.patch?.(); } route(); refresh(); setInterval(refresh, 30000); document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); }); })();