SPB Git forge
1commits 1branches 0releases
28.0 KBsize
maindefault branch
1 h agolast push
HTML 61.4% JavaScript 24.5% Shell 14.1%
14.9 KB · 263 lines html
Raw Blame History
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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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