spb/maclustr-appmanager
Public
HTML 61.4%
JavaScript 24.5%
Shell 14.1%
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>122123<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>128129<script>130const $ = (s)=>document.querySelector(s);131let DATA = null, AUTO = true, TIMER = null, COUNT = 20;132133function 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]; }134function base(p){ if(!p) return ""; const x=p.replace(/\/$/,"").split("/"); return x[x.length-1]||p; }135function cls(pct){ return pct>=90?"crit":pct>=70?"warn":""; }136function esc(s){ return (s==null?"":String(s)).replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])); }137138function 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}143144function 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||[];160161 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>';166167 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>';171172 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>';180181 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();182183 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}202203function 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");220221 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}227228function 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}234235async 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}248249function tick(){250 if(!AUTO) return;251 COUNT--; if(COUNT<=0){ load(); }252 $("#auto").textContent = AUTO? ("auto "+Math.max(COUNT,0)+"s") : "auto off";253}254255$("#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"; };258setInterval(tick, 1000);259load();260</script>261</body>262</html>263