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