SPB Git forge
1commits 1branches 0releases
28.0 KBsize
maindefault branch
1 h agolast push
HTML 61.4% JavaScript 24.5% Shell 14.1%

adopt: état déployé sur M2U64 adopté comme source de vérité (remote-first, 2026-10-02)

mld committed 1 h ago (Oct 2, 2026)

4 changed files +467 −0

added .gitignore +11 −0
@@ -0,0 +1,11 @@
1 +node_modules/
2 +.venv/
3 +venv/
4 +.next/
5 +__pycache__/
6 +*.pyc
7 +*.log
8 +.DS_Store
9 +.env
10 +.env.local
11 +.pm2/
added collector.sh +64 −0
@@ -0,0 +1,64 @@
1 +#!/bin/sh
2 +# maclustr app-manager — POSIX-sh per-node collector (no python/jq needed).
3 +# Emits a simple line protocol parsed by the Node server.
4 +echo "HOST $(hostname -s 2>/dev/null)"
5 +echo "CORES $(sysctl -n hw.ncpu 2>/dev/null)"
6 +echo "MEMTOTAL $(sysctl -n hw.memsize 2>/dev/null)"
7 +
8 +ps=$(sysctl -n hw.pagesize 2>/dev/null); [ -z "$ps" ] && ps=16384
9 +vms=$(vm_stat 2>/dev/null)
10 +act=$(printf '%s\n' "$vms" | awk '/Pages active/{gsub(/[^0-9]/,"",$NF);print $NF}')
11 +wir=$(printf '%s\n' "$vms" | awk '/Pages wired/{gsub(/[^0-9]/,"",$NF);print $NF}')
12 +cmp=$(printf '%s\n' "$vms" | awk '/occupied by compressor/{gsub(/[^0-9]/,"",$NF);print $NF}')
13 +act=${act:-0}; wir=${wir:-0}; cmp=${cmp:-0}
14 +echo "MEMUSED $(( (act + wir + cmp) * ps ))"
15 +
16 +la=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}')
17 +[ -z "$(echo $la | tr -d ' ')" ] && la=$(uptime | sed -n 's/.*averages*[:,]* *\([0-9.]*\)[ ,]*\([0-9.]*\)[ ,]*\([0-9.]*\).*/\1 \2 \3/p')
18 +echo "LOAD $la"
19 +echo "BOOT $(sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec = \([0-9]*\).*/\1/p')"
20 +df -k / 2>/dev/null | tail -1 | awk '{print "DISK "$2*1024" "$3*1024" "$5}'
21 +
22 +# listening TCP ports (skip pure-OS services)
23 +lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | tail -n +2 | while read -r cmd pid user fd type dev sz nod name rest; do
24 + case "$name" in *:*) ;; *) continue ;; esac
25 + case "$cmd" in ARDAgent|rapportd|sshd|launchd|mDNSResponder|identityservicesd|ControlCe|ControlCenter|sharingd|remoted|AnyDesk|anydesk|Google*) continue ;; esac
26 + port=${name##*:}; host=${name%:*}
27 + case "$port" in ''|*[!0-9]*) continue ;; esac
28 + lb=0; case "$host" in 127.0.0.1|"[::1]"|"::1"|"*") lb=1 ;; esac
29 + cwd=$(lsof -p "$pid" -a -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)
30 + printf 'PORT %s\t%s\t%s\t%s\n' "$port" "$cmd" "$lb" "$cwd"
31 +done
32 +
33 +# ngrok tunnels — split JSON into per-tunnel objects (POSIX sh, no process subst)
34 +for p in 4040 4041 4042 4043 4044 4045 4046 4047 4048; do
35 + j=$(curl -s --max-time 1 "http://127.0.0.1:$p/api/tunnels" 2>/dev/null)
36 + [ -z "$j" ] && continue
37 + { printf '%s' "$j" | sed 's/},{/}@{/g' | tr '@' '\n'; echo; } | while IFS= read -r obj; do
38 + u=$(printf '%s' "$obj" | grep -o '"public_url":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//')
39 + [ -z "$u" ] && continue
40 + a=$(printf '%s' "$obj" | grep -o '"addr":"[^"]*"' | head -1 | sed 's/.*:"//;s/"$//')
41 + printf 'TUN %s\t%s\n' "$u" "$a"
42 + done
43 +done
44 +
45 +# launchd: user agents + relevant system daemons
46 +loaded=$(launchctl list 2>/dev/null)
47 +for plist in "$HOME"/Library/LaunchAgents/*.plist; do
48 + [ -e "$plist" ] || continue
49 + label=$(basename "$plist" .plist)
50 + printf '%s' "$label" | grep -qiE 'apple|google|micro|docker|tailscale|openssh|knollsoft|anydesk' && continue
51 + info=$(printf '%s\n' "$loaded" | awk -v L="$label" '$3==L{print $1}')
52 + if [ -n "$info" ]; then ld=1; pid="$info"; run=0; { [ "$pid" != "-" ] && [ -n "$pid" ]; } && run=1; else ld=0; pid="-"; run=0; fi
53 + printf 'AGENT %s\tuser\t%s\t%s\t%s\n' "$label" "$ld" "$run" "$pid"
54 +done
55 +for plist in /Library/LaunchDaemons/*.plist; do
56 + [ -e "$plist" ] || continue
57 + label=$(basename "$plist" .plist)
58 + printf '%s' "$label" | grep -qiE 'apple|openssh|homebrew' && continue
59 + printf '%s' "$label" | grep -qiE 'maclustr|ngrok|allconverter|vquant|spb|louka|zyquo|al-l|localai|crawl|image|drive' || continue
60 + info=$(printf '%s\n' "$loaded" | awk -v L="$label" '$3==L{print $1}')
61 + if [ -n "$info" ]; then ld=1; pid="$info"; run=1; else ld=0; pid="-"; run=0; fi
62 + printf 'AGENT %s\tsystem\t%s\t%s\t%s\n' "$label" "$ld" "$run" "$pid"
63 +done
64 +echo "END"
added index.html +262 −0
@@ -0,0 +1,262 @@
1 +<!doctype html>
2 +<html lang="fr" class="dark">
3 +<head>
4 +<meta charset="utf-8" />
5 +<meta name="viewport" content="width=device-width, initial-scale=1" />
6 +<title>maclustr · App Manager</title>
7 +<link rel="preconnect" href="https://fonts.googleapis.com" />
8 +<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
9 +<style>
10 + :root{
11 + --bg:#0a0a10; --bg-soft:#0f0f17; --card:#13131d; --elev:#1b1b27; --line:#26263a;
12 + --ink:#f4f5fa; --ink2:#c3c8da; --ink3:#8a90a6; --ink4:#5b6076;
13 + --brand:#ff4d1c; --amber:#ff9f1c; --flame:#ff2d78;
14 + --hot:linear-gradient(120deg,#ff9f1c 0%,#ff4d1c 46%,#ff2d78 100%);
15 + --green:#22c55e; --red:#ff4d4d;
16 + }
17 + *{box-sizing:border-box}
18 + html,body{margin:0;height:100%}
19 + body{
20 + background:var(--bg); color:var(--ink2);
21 + font-family:Inter,system-ui,-apple-system,sans-serif; -webkit-font-smoothing:antialiased;
22 + background-image:radial-gradient(72% 50% at 50% -8%, rgba(255,77,28,.16), transparent 70%),
23 + radial-gradient(40% 40% at 100% 0%, rgba(255,45,120,.08), transparent 60%);
24 + background-attachment:fixed;
25 + }
26 + ::selection{background:rgba(255,77,28,.4);color:#fff}
27 + *::-webkit-scrollbar{width:9px;height:9px}
28 + *::-webkit-scrollbar-thumb{background:var(--line);border-radius:9px}
29 + a{color:inherit}
30 + .wrap{max-width:1500px;margin:0 auto;padding:22px 22px 80px}
31 + /* header */
32 + header{position:sticky;top:0;z-index:30;backdrop-filter:blur(14px);
33 + background:rgba(10,10,16,.72);border-bottom:1px solid var(--line)}
34 + .hd{max-width:1500px;margin:0 auto;display:flex;align-items:center;gap:14px;padding:12px 22px}
35 + .logo{position:relative;display:grid;place-items:center;width:40px;height:40px;border-radius:13px;
36 + background:var(--hot);box-shadow:0 8px 30px -6px rgba(255,77,28,.55);overflow:hidden;flex:none}
37 + .logo:after{content:"";position:absolute;inset:0;background:linear-gradient(135deg,rgba(255,255,255,.45),transparent 60%);opacity:.6}
38 + .logo svg{position:relative;width:22px;height:22px;color:#fff}
39 + .brandtxt{font-weight:800;font-size:18px;letter-spacing:-.02em;color:var(--ink)}
40 + .grad{background:var(--hot);-webkit-background-clip:text;background-clip:text;color:transparent}
41 + .sub{font-size:12px;color:var(--ink3);margin-top:1px}
42 + .spacer{flex:1}
43 + .pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);background:var(--card);
44 + border-radius:999px;padding:6px 12px;font-size:12px;color:var(--ink2)}
45 + .dot{width:8px;height:8px;border-radius:50%;background:var(--brand);box-shadow:0 0 0 0 rgba(255,77,28,.5);animation:pr 2.2s infinite}
46 + @keyframes pr{0%{box-shadow:0 0 0 0 rgba(255,77,28,.45)}70%{box-shadow:0 0 0 9px rgba(255,77,28,0)}100%{box-shadow:0 0 0 0 rgba(255,77,28,0)}}
47 + .btn{cursor:pointer;border:1px solid var(--line);background:var(--elev);color:var(--ink2);
48 + border-radius:11px;padding:8px 13px;font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:7px;transition:.15s}
49 + .btn:hover{border-color:rgba(255,77,28,.5);color:var(--ink)}
50 + .btn.hot{background:var(--hot);color:#fff;border:none;box-shadow:0 8px 30px -6px rgba(255,77,28,.55)}
51 + input.search{background:var(--bg-soft);border:1px solid var(--line);border-radius:11px;color:var(--ink);
52 + padding:8px 12px;font-size:13px;font-family:inherit;width:200px;outline:none}
53 + input.search:focus{border-color:rgba(255,77,28,.6)}
54 + /* summary */
55 + .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:13px;margin:22px 0}
56 + .stat{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px 18px}
57 + .stat .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink3);font-weight:700}
58 + .stat .val{font-size:30px;font-weight:800;color:var(--ink);margin-top:6px;line-height:1}
59 + .stat .hint{font-size:12px;color:var(--ink4);margin-top:5px}
60 + /* grid */
61 + .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px}
62 + .node{background:var(--card);border:1px solid var(--line);border-radius:20px;padding:0;overflow:hidden;
63 + box-shadow:0 1px 0 rgba(255,255,255,.03) inset, 0 10px 34px -16px rgba(0,0,0,.6);transition:.18s}
64 + .node:hover{border-color:rgba(255,77,28,.35)}
65 + .node.down{opacity:.55}
66 + .nhd{display:flex;align-items:center;gap:11px;padding:15px 17px;border-bottom:1px solid var(--line)}
67 + .nstat{width:9px;height:9px;border-radius:50%;flex:none}
68 + .nstat.up{background:var(--green);box-shadow:0 0 8px var(--green)}
69 + .nstat.dn{background:var(--red)}
70 + .nname{font-weight:800;font-size:17px;color:var(--ink);letter-spacing:-.01em}
71 + .nmeta{font-size:11.5px;color:var(--ink3);font-family:JetBrains Mono,monospace}
72 + .nmodel{margin-left:auto;font-size:11px;color:var(--ink4);text-align:right}
73 + .bars{display:grid;grid-template-columns:repeat(3,1fr);gap:11px;padding:14px 17px}
74 + .bar .top{display:flex;justify-content:space-between;font-size:11px;color:var(--ink3);margin-bottom:5px;font-weight:600}
75 + .bar .top b{color:var(--ink2);font-weight:700}
76 + .track{height:7px;border-radius:6px;background:var(--elev);overflow:hidden}
77 + .fill{height:100%;border-radius:6px;background:var(--hot);transition:width .4s}
78 + .fill.warn{background:linear-gradient(90deg,#ff9f1c,#ff7a1c)}
79 + .fill.crit{background:linear-gradient(90deg,#ff4d4d,#ff2d78)}
80 + .sec{padding:6px 17px 14px}
81 + .sech{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--ink3);font-weight:700;
82 + display:flex;align-items:center;gap:7px;margin:11px 0 8px}
83 + .sech .c{margin-left:auto;background:var(--elev);border-radius:999px;padding:1px 8px;color:var(--ink4);font-size:10.5px}
84 + .row{display:flex;align-items:center;gap:9px;padding:6px 9px;border-radius:10px;font-size:12.5px}
85 + .row:hover{background:var(--bg-soft)}
86 + .port{font-family:JetBrains Mono,monospace;font-weight:700;color:var(--brand);background:rgba(255,77,28,.1);
87 + border:1px solid rgba(255,77,28,.25);border-radius:7px;padding:1px 7px;font-size:11.5px;flex:none;min-width:48px;text-align:center}
88 + .pproc{color:var(--ink);font-weight:600}
89 + .pcwd{color:var(--ink4);font-family:JetBrains Mono,monospace;font-size:11px;margin-left:auto;
90 + max-width:46%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
91 + .lb{font-size:9.5px;color:var(--ink4);border:1px solid var(--line);border-radius:5px;padding:0 4px}
92 + .tun{color:var(--amber);font-weight:600;text-decoration:none}
93 + .tun:hover{text-decoration:underline}
94 + .tarrow{color:var(--ink4)}
95 + .taddr{color:var(--ink3);font-family:JetBrains Mono,monospace;font-size:11px;margin-left:auto}
96 + .badge{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;border-radius:6px;padding:2px 7px;flex:none}
97 + .b-run{color:var(--green);background:rgba(34,197,94,.12);border:1px solid rgba(34,197,94,.3)}
98 + .b-load{color:var(--amber);background:rgba(255,159,28,.12);border:1px solid rgba(255,159,28,.3)}
99 + .b-off{color:var(--ink4);background:var(--elev);border:1px solid var(--line)}
100 + .b-sys{color:var(--flame);background:rgba(255,45,120,.1);border:1px solid rgba(255,45,120,.3)}
101 + .alabel{color:var(--ink2);font-family:JetBrains Mono,monospace;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
102 + .empty{color:var(--ink4);font-size:12px;padding:4px 9px;font-style:italic}
103 + .err{color:var(--red);font-size:11.5px;padding:8px 17px 16px;font-family:JetBrains Mono,monospace}
104 + .loading{text-align:center;color:var(--ink3);padding:60px;font-size:15px}
105 + .foot{margin-top:30px;text-align:center;color:var(--ink4);font-size:11.5px}
106 +</style>
107 +</head>
108 +<body>
109 +<header>
110 + <div class="hd">
111 + <div class="logo"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3.2" fill="currentColor"/><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 2.5v3"/><path d="M12 18.5v3"/><path d="M2.5 12h3"/><path d="M18.5 12h3"/><path d="M5 5l2.2 2.2"/><path d="M16.8 16.8L19 19"/><path d="M19 5l-2.2 2.2"/><path d="M7.2 16.8L5 19"/></g></svg></div>
112 + <div>
113 + <div class="brandtxt">mac<span class="grad">lustr</span> · App Manager</div>
114 + <div class="sub" id="subtitle">Chargement du cluster…</div>
115 + </div>
116 + <div class="spacer"></div>
117 + <input class="search" id="filter" placeholder="Filtrer node / app / port / tunnel…" />
118 + <span class="pill"><span class="dot"></span><span id="auto">auto 20s</span></span>
119 + <button class="btn hot" id="refresh">↻ Rafraîchir</button>
120 + </div>
121 +</header>
122 +
123 +<div class="wrap">
124 + <div class="stats" id="stats"></div>
125 + <div id="content"><div class="loading">⏳ Interrogation des 20 nodes…</div></div>
126 + <div class="foot" id="foot"></div>
127 +</div>
128 +
129 +<script>
130 +const $ = (s)=>document.querySelector(s);
131 +let DATA = null, AUTO = true, TIMER = null, COUNT = 20;
132 +
133 +function fmtBytes(b){ if(!b) return "—"; const u=["B","KB","MB","GB","TB"]; let i=0,n=b; while(n>=1024&&i<u.length-1){n/=1024;i++;} return n.toFixed(n<10&&i>0?1:0)+" "+u[i]; }
134 +function base(p){ if(!p) return ""; const x=p.replace(/\/$/,"").split("/"); return x[x.length-1]||p; }
135 +function cls(pct){ return pct>=90?"crit":pct>=70?"warn":""; }
136 +function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); }
137 +
138 +function bar(label, pct, txt){
139 + pct = Math.max(0, Math.min(100, pct||0));
140 + return `<div class="bar"><div class="top"><span>${label}</span><b>${txt}</b></div>
141 + <div class="track"><div class="fill ${cls(pct)}" style="width:${pct}%"></div></div></div>`;
142 +}
143 +
144 +function nodeCard(n){
145 + const meta = n.meta || {};
146 + if(!n.reachable){
147 + return `<div class="node down" data-blob="${esc((n.alias+' '+(meta.model||'')).toLowerCase())}">
148 + <div class="nhd"><span class="nstat dn"></span><span class="nname">${esc(n.alias)}</span>
149 + <span class="nmeta">injoignable</span><span class="nmodel">${esc(meta.model||"")}</span></div>
150 + <div class="err">⚠ ${esc(n.error||"timeout / SSH")}</div></div>`;
151 + }
152 + const memPct = n.mem_total? (n.mem_used/n.mem_total*100):0;
153 + const load1 = (n.load&&n.load.length)? n.load[0]:0;
154 + const loadPct = n.cores? (load1/n.cores*100):0;
155 + const diskPct = parseFloat((n.disk_pct||"0").replace("%",""))||0;
156 + const upDays = n.boot_epoch? Math.floor((Date.now()/1000 - n.boot_epoch)/86400):null;
157 + const ports = (n.ports||[]).filter(p=>p.proc!=="ngrok");
158 + const tunnels = n.tunnels||[];
159 + const agents = n.agents||[];
160 +
161 + const portRows = ports.length? ports.map(p=>`<div class="row">
162 + <span class="port">${p.port}</span><span class="pproc">${esc(p.proc)}</span>
163 + ${p.loopback?'<span class="lb">local</span>':''}
164 + <span class="pcwd" title="${esc(p.cwd)}">${esc(base(p.cwd))}</span></div>`).join("")
165 + : '<div class="empty">aucun service en écoute</div>';
166 +
167 + const tunRows = tunnels.length? tunnels.map(t=>`<div class="row">
168 + <a class="tun" href="${esc(t.url)}" target="_blank" rel="noopener">${esc((t.url||"").replace("https://",""))}</a>
169 + <span class="tarrow">→</span><span class="taddr">${esc(t.addr)}</span></div>`).join("")
170 + : '<div class="empty">aucun tunnel ngrok</div>';
171 +
172 + const agRows = agents.length? agents.map(a=>{
173 + let b = a.scope==="system"
174 + ? '<span class="badge b-sys">root</span>'
175 + : (a.running? '<span class="badge b-run">running</span>'
176 + : a.loaded? '<span class="badge b-load">loaded</span>'
177 + : '<span class="badge b-off">stopped</span>');
178 + return `<div class="row"><span class="alabel" title="${esc(a.label)}">${esc(a.label)}</span>${b}</div>`;
179 + }).join("") : '<div class="empty">aucun agent custom</div>';
180 +
181 + const blob = (n.alias+' '+(meta.model||'')+' '+n.hostname+' '+ports.map(p=>p.port+' '+p.proc+' '+p.cwd).join(' ')+' '+tunnels.map(t=>t.url).join(' ')+' '+agents.map(a=>a.label).join(' ')).toLowerCase();
182 +
183 + return `<div class="node" data-blob="${esc(blob)}">
184 + <div class="nhd">
185 + <span class="nstat up"></span>
186 + <div><div class="nname">${esc(n.alias)}</div><div class="nmeta">${esc(n.hostname)}</div></div>
187 + <div class="nmodel">${esc(meta.model||"")}<br>${n.cores||"?"} cœurs · ${fmtBytes(n.mem_total)}</div>
188 + </div>
189 + <div class="bars">
190 + ${bar("Charge", loadPct, load1.toFixed(2))}
191 + ${bar("RAM", memPct, fmtBytes(n.mem_used))}
192 + ${bar("Disque", diskPct, n.disk_pct||"—")}
193 + </div>
194 + <div class="sec">
195 + <div class="sech">⬡ Apps & ports <span class="c">${ports.length}</span></div>${portRows}
196 + <div class="sech">🌐 Tunnels <span class="c">${tunnels.length}</span></div>${tunRows}
197 + <div class="sech">⚙ launchd / daemons <span class="c">${agents.length}</span></div>${agRows}
198 + <div class="empty" style="text-align:right">${upDays!=null?("uptime ~"+upDays+" j"):""}</div>
199 + </div>
200 + </div>`;
201 +}
202 +
203 +function render(){
204 + if(!DATA) return;
205 + const nodes = DATA.nodes||[];
206 + const up = nodes.filter(n=>n.reachable);
207 + const apps = up.reduce((s,n)=>s+((n.ports||[]).filter(p=>p.proc!=="ngrok").length),0);
208 + const tuns = up.reduce((s,n)=>s+((n.tunnels||[]).length),0);
209 + const agts = up.reduce((s,n)=>s+((n.agents||[]).filter(a=>a.running).length),0);
210 + const cores = up.reduce((s,n)=>s+(n.cores||0),0);
211 + const ram = up.reduce((s,n)=>s+(n.mem_total||0),0);
212 + const stat=(l,v,h)=>`<div class="stat"><div class="lbl">${l}</div><div class="val">${v}</div><div class="hint">${h||""}</div></div>`;
213 + $("#stats").innerHTML =
214 + stat("Nodes en ligne", up.length+" / "+nodes.length, "cluster maclustr") +
215 + stat("Apps déployées", apps, "services en écoute") +
216 + stat("Tunnels actifs", tuns, "ngrok publics") +
217 + stat("Daemons actifs", agts, "launchd running") +
218 + stat("Cœurs", cores, "CPU cumulés") +
219 + stat("RAM", fmtBytes(ram), "mémoire cumulée");
220 +
221 + const order = nodes.slice().sort((a,b)=> (b.reachable-a.reachable) || (b.cores||0)-(a.cores||0) || a.alias.localeCompare(b.alias));
222 + $("#content").innerHTML = '<div class="grid">'+order.map(nodeCard).join("")+'</div>';
223 + applyFilter();
224 + const d = new Date(DATA.generatedAt);
225 + $("#subtitle").textContent = up.length+" nodes en ligne · maj "+d.toLocaleTimeString("fr-FR")+" · "+(DATA.tookMs||0)+" ms";
226 +}
227 +
228 +function applyFilter(){
229 + const q = $("#filter").value.trim().toLowerCase();
230 + document.querySelectorAll(".node").forEach(el=>{
231 + el.style.display = (!q || (el.dataset.blob||"").includes(q)) ? "" : "none";
232 + });
233 +}
234 +
235 +async function load(){
236 + $("#refresh").textContent = "↻ …";
237 + try{
238 + const r = await fetch("/api/cluster?ts="+Date.now());
239 + DATA = await r.json();
240 + render();
241 + $("#foot").textContent = "maclustr app-manager · "+(DATA.nodes||[]).length+" nodes · données live via SSH";
242 + }catch(e){
243 + $("#content").innerHTML = '<div class="loading">❌ Erreur de chargement : '+esc(e.message)+'</div>';
244 + }
245 + $("#refresh").textContent = "↻ Rafraîchir";
246 + COUNT = 20;
247 +}
248 +
249 +function tick(){
250 + if(!AUTO) return;
251 + COUNT--; if(COUNT<=0){ load(); }
252 + $("#auto").textContent = AUTO? ("auto "+Math.max(COUNT,0)+"s") : "auto off";
253 +}
254 +
255 +$("#refresh").onclick = load;
256 +$("#filter").oninput = applyFilter;
257 +$("#auto").parentElement.onclick = (e)=>{ if(e.target.id==="refresh")return; AUTO=!AUTO; COUNT=20; $("#auto").textContent=AUTO?"auto 20s":"auto off"; };
258 +setInterval(tick, 1000);
259 +load();
260 +</script>
261 +</body>
262 +</html>
added server.mjs +130 −0
@@ -0,0 +1,130 @@
1 +// maclustr app-manager — dependency-free Node server.
2 +// Serves the dashboard + /api/cluster (live SSH fan-out across all nodes).
3 +import http from "node:http";
4 +import { spawn } from "node:child_process";
5 +import { readFileSync } from "node:fs";
6 +import { fileURLToPath } from "node:url";
7 +import { dirname, join } from "node:path";
8 +
9 +const DIR = dirname(fileURLToPath(import.meta.url));
10 +const PORT = parseInt(process.env.PORT || "8787", 10);
11 +const COLLECTOR = readFileSync(join(DIR, "collector.sh"), "utf8");
12 +const HTML = readFileSync(join(DIR, "index.html"), "utf8");
13 +
14 +// Parse the collector's line protocol into the JSON shape the UI expects.
15 +function parseCollector(text) {
16 + const o = { ports: [], tunnels: [], agents: [], load: [] };
17 + for (const line of text.split("\n")) {
18 + const sp = line.indexOf(" ");
19 + if (sp < 0) continue;
20 + const tag = line.slice(0, sp), rest = line.slice(sp + 1);
21 + if (tag === "HOST") o.hostname = rest.trim();
22 + else if (tag === "CORES") o.cores = parseInt(rest) || null;
23 + else if (tag === "MEMTOTAL") o.mem_total = parseInt(rest) || 0;
24 + else if (tag === "MEMUSED") o.mem_used = parseInt(rest) || 0;
25 + else if (tag === "LOAD") o.load = rest.trim().split(/\s+/).map(Number).filter(x => !isNaN(x)).slice(0, 3);
26 + else if (tag === "BOOT") o.boot_epoch = parseInt(rest) || null;
27 + else if (tag === "DISK") { const [t, u, p] = rest.trim().split(/\s+/); o.disk_total = parseInt(t) || 0; o.disk_used = parseInt(u) || 0; o.disk_pct = p || ""; }
28 + else if (tag === "PORT") { const [port, proc, lb, cwd] = rest.split("\t"); o.ports.push({ port: parseInt(port), proc, loopback: lb === "1", cwd: cwd || "" }); }
29 + else if (tag === "TUN") { const [url, addr] = rest.split("\t"); o.tunnels.push({ url, addr: addr || "" }); }
30 + else if (tag === "AGENT") { const [label, scope, ld, run, pid] = rest.split("\t"); o.agents.push({ label, scope, loaded: ld === "1", running: run === "1", pid }); }
31 + }
32 + o.ports.sort((a, b) => a.port - b.port);
33 + return o;
34 +}
35 +
36 +// alias -> display model (cores/ram are read live from each node)
37 +const NODES = [
38 + ["M3U96a","Mac Studio"], ["M3U96b","Mac Studio"], ["M2U64","Mac Studio"],
39 + ["M4M64a","Mac Studio"], ["M4M64b","Mac Studio"], ["M4M36","Mac Studio"],
40 + ["M2M32","Mac Studio"], ["M2M32b","Mac Studio"], ["M2M32c","Mac Studio"],
41 + ["M1M32","Mac Studio"], ["M4BP48","MacBook Pro"], ["M4BP36","MacBook Pro"],
42 + ["M3BA24","MacBook Air"], ["M3BA16","MacBook Air"], ["m4mc","Mac mini"],
43 + ["m4ma","Mac mini"], ["m4mb","Mac mini"], ["m2m16","Mac mini"],
44 + ["m2m8a","Mac mini"], ["m2m8b","Mac mini"],
45 +];
46 +
47 +const SSH_OPTS = [
48 + "-o","BatchMode=yes","-o","StrictHostKeyChecking=accept-new",
49 + "-o","ConnectTimeout=6","-o","ServerAliveInterval=3","-o","ServerAliveCountMax=2",
50 +];
51 +
52 +function collectNode([alias, model]) {
53 + return new Promise((resolve) => {
54 + let out = "", err = "", done = false;
55 + const finish = (o) => { if (!done) { done = true; resolve(o); } };
56 + const env = { ...process.env };
57 + delete env.SSH_AUTH_SOCK; // use M2U64's own key, not a forwarded agent
58 + const p = spawn("ssh", [...SSH_OPTS, alias, "sh"], { env });
59 + const kill = setTimeout(() => { try { p.kill("SIGKILL"); } catch {}
60 + finish({ alias, meta: { model }, reachable: false, error: "timeout (>18s)" }); }, 18000);
61 + p.stdout.on("data", d => out += d);
62 + p.stderr.on("data", d => err += d);
63 + p.on("error", e => { clearTimeout(kill); finish({ alias, meta:{model}, reachable:false, error:String(e.message||e).slice(0,160) }); });
64 + p.on("close", () => {
65 + clearTimeout(kill);
66 + if (out.indexOf("HOST ") < 0) return finish({ alias, meta:{model}, reachable:false, error:(err.trim()||"no output").slice(0,160) });
67 + const data = parseCollector(out);
68 + finish({ alias, meta:{model}, reachable: !!data.hostname, ...data });
69 + });
70 + try { p.stdin.write(COLLECTOR); p.stdin.end(); } catch {}
71 + });
72 +}
73 +
74 +// bounded-concurrency fan-out
75 +async function collectAll(limit = 10) {
76 + const t0 = Date.now();
77 + const queue = [...NODES];
78 + const results = [];
79 + async function worker() {
80 + while (queue.length) {
81 + const n = queue.shift();
82 + results.push(await collectNode(n));
83 + }
84 + }
85 + await Promise.all(Array.from({ length: Math.min(limit, NODES.length) }, worker));
86 + return { generatedAt: Date.now(), tookMs: Date.now() - t0, nodes: results };
87 +}
88 +
89 +// cache + single-flight
90 +let cache = null, cacheTs = 0, inflight = null;
91 +const TTL = 12000;
92 +async function getCluster(force = false) {
93 + const now = Date.now();
94 + if (!force && cache && now - cacheTs < TTL) return cache;
95 + if (inflight) return inflight;
96 + inflight = collectAll().then(r => { cache = r; cacheTs = Date.now(); inflight = null; return r; })
97 + .catch(e => { inflight = null; throw e; });
98 + return inflight;
99 +}
100 +
101 +const server = http.createServer(async (req, res) => {
102 + const url = (req.url || "/").split("?")[0];
103 + try {
104 + if (url === "/" || url === "/index.html") {
105 + res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
106 + return res.end(HTML);
107 + }
108 + if (url === "/healthz") {
109 + res.writeHead(200, { "content-type": "application/json" });
110 + return res.end(JSON.stringify({ ok: true, nodes: NODES.length, cachedAt: cacheTs }));
111 + }
112 + if (url === "/api/cluster") {
113 + const data = await getCluster(false);
114 + res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
115 + return res.end(JSON.stringify(data));
116 + }
117 + res.writeHead(404, { "content-type": "text/plain" });
118 + res.end("not found");
119 + } catch (e) {
120 + res.writeHead(500, { "content-type": "application/json" });
121 + res.end(JSON.stringify({ error: String(e && e.message || e) }));
122 + }
123 +});
124 +
125 +server.listen(PORT, "0.0.0.0", () => {
126 + console.log(`[maclustr-app-manager] listening on :${PORT} — ${NODES.length} nodes`);
127 + getCluster(true).then(r => console.log(`[startup] collected ${r.nodes.filter(n=>n.reachable).length}/${r.nodes.length} in ${r.tookMs}ms`)).catch(()=>{});
128 +});
129 +// background warm refresh
130 +setInterval(() => { getCluster(true).catch(()=>{}); }, 20000);
131