← back to Fleet Tv
public/fleet.html
122 lines
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Fleet Health — pm2 status grid</title>
<style>
:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--yellow:#c9a14b;--red:#c66c4d;--gray:#3a3326;}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--ink);font:300 13px -apple-system,sans-serif;min-height:100vh}
header{padding:18px 26px;border-bottom:1px solid var(--rule);background:var(--bg2);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:14px}
h1{font:600 11px/1 inherit;letter-spacing:.32em;text-transform:uppercase;color:var(--gold)}
.pills{display:flex;gap:8px;flex-wrap:wrap}
.pill{padding:5px 12px;border:1px solid var(--rule);font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
.pill.online{color:var(--green);border-color:var(--green)}
.pill.errored{color:var(--red);border-color:var(--red)}
.pill.stopped{color:var(--gray);border-color:var(--gray)}
.pill.launching{color:var(--yellow);border-color:var(--yellow)}
.toolbar{padding:12px 26px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.toolbar input{flex:1;min-width:200px;background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
.toolbar select{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
main{padding:18px 26px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px}
.tile{background:var(--bg2);border:1px solid var(--rule);padding:10px 12px;border-left-width:3px;font-family:ui-monospace,SF Mono,monospace;font-size:11px;line-height:1.4;transition:all .15s}
.tile:hover{background:#1a160f}
.tile.online{border-left-color:var(--green)}
.tile.errored{border-left-color:var(--red);animation:pulse 2s ease-in-out infinite}
.tile.stopped{border-left-color:var(--gray);opacity:.55}
.tile.launching,.tile.stopping{border-left-color:var(--yellow)}
@keyframes pulse{0%,100%{background:var(--bg2)}50%{background:rgba(198,108,77,0.15)}}
.tile .name{color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.tile .row{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:3px}
.tile.online .row .stat{color:var(--green)}
.tile.errored .row .stat{color:var(--red)}
.refresh{font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
</style></head><body>
<header>
<h1>Fleet Health</h1>
<div class="pills" id="pills"></div>
<span class="refresh" id="refresh">…</span>
</header>
<div class="toolbar">
<input id="search" placeholder="filter by name…" />
<select id="status-filter">
<option value="">all</option>
<option value="online">online only</option>
<option value="errored">errored only</option>
<option value="stopped">stopped only</option>
</select>
<select id="sort">
<option value="name">sort by name</option>
<option value="restarts">most restarts</option>
<option value="memory">highest memory</option>
<option value="uptime">newest uptime</option>
</select>
</div>
<main><div id="grid" class="grid"></div></main>
<div id="modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;padding:30px;overflow:auto" onclick="if(event.target===this)this.style.display='none'">
<div style="max-width:1080px;margin:0 auto;background:var(--bg2);border:1px solid var(--gold);padding:18px;font-family:ui-monospace,monospace;font-size:11px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<strong id="modal-name" style="color:var(--gold);font-size:13px;letter-spacing:.10em"></strong>
<button onclick="document.getElementById('modal').style.display='none'" style="background:none;border:1px solid var(--rule);color:var(--ink);padding:4px 12px;cursor:pointer;font:inherit;font-size:11px">Close</button>
</div>
<div style="color:var(--green);font-size:9px;letter-spacing:.14em;margin:6px 0">STDOUT (last 50)</div>
<pre id="modal-stdout" style="white-space:pre-wrap;color:var(--ink);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
<div style="color:var(--red);font-size:9px;letter-spacing:.14em;margin:10px 0 6px">STDERR (last 50)</div>
<pre id="modal-stderr" style="white-space:pre-wrap;color:var(--red);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
</div>
</div>
<script>
const $=id=>document.getElementById(id);
let DATA={services:[],counts:{}};
let SEARCH='',STATUS='',SORT='name';
function fmt(ms){const s=Math.floor(ms/1000);if(s<60)return s+'s';const m=Math.floor(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);if(h<24)return h+'h';return Math.floor(h/24)+'d'}
function render(){
let list=DATA.services.filter(s=>{
if(STATUS && s.status!==STATUS)return false;
if(SEARCH && !s.name.toLowerCase().includes(SEARCH))return false;
return true;
});
if(SORT==='name')list.sort((a,b)=>a.name.localeCompare(b.name));
else if(SORT==='restarts')list.sort((a,b)=>(b.restarts||0)-(a.restarts||0));
else if(SORT==='memory')list.sort((a,b)=>(b.memory_mb||0)-(a.memory_mb||0));
else if(SORT==='uptime')list.sort((a,b)=>(b.uptime_ms||0)-(a.uptime_ms||0));
$('grid').innerHTML=list.map(s=>`<div class="tile ${s.status}" data-name="${s.name}" title="click for logs · pid ${s.pid||'-'}" style="cursor:pointer">
<div class="name">${s.name}</div>
<div class="row"><span class="stat">${s.status}</span><span>↻${s.restarts||0}</span></div>
<div class="row"><span>${s.memory_mb||0}MB</span><span>${s.cpu||0}%</span><span>${fmt(s.uptime_ms||0)}</span></div>
</div>`).join('');
document.querySelectorAll('.tile[data-name]').forEach(t=>t.addEventListener('click',async()=>{
const name=t.dataset.name;
$('modal-name').textContent=name;
$('modal-stdout').textContent='loading…';
$('modal-stderr').textContent='';
$('modal').style.display='block';
try{const j=await fetch('/api/fleet-logs/'+encodeURIComponent(name)).then(r=>r.json());
$('modal-stdout').textContent=j.stdout||'(empty)';
$('modal-stderr').textContent=j.stderr||'(empty)';
}catch(e){$('modal-stdout').textContent='err: '+e.message}
}));
$('pills').innerHTML=Object.entries(DATA.counts).sort().map(([k,v])=>`<span class="pill ${k}">${v} ${k}</span>`).join('') + ` <span class="pill">${list.length}/${DATA.services.length}</span>`;
}
async function load(){
try{
const r=await fetch('/api/fleet-status').then(r=>r.json());
DATA=r;
render();
$('refresh').textContent='UPDATED '+new Date().toLocaleTimeString();
}catch(e){$('refresh').textContent='ERR: '+e.message}
}
$('search').addEventListener('input',e=>{SEARCH=e.target.value.toLowerCase();render()});
$('status-filter').addEventListener('change',e=>{STATUS=e.target.value;render()});
$('sort').addEventListener('change',e=>{SORT=e.target.value;render()});
load();
setInterval(load,10000);
</script>
</body></html>