← back to Pm2 Fleet Viewer
public/index.html
171 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PM2 Fleet Viewer</title>
<style>
:root { --cols: 4; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0e1117; color: #e6edf3; }
header { position: sticky; top: 0; z-index: 5; background: #161b22; border-bottom: 1px solid #30363d;
padding: 12px 18px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
h1 { font-size: 16px; margin: 0; font-weight: 600; letter-spacing: .3px; }
.stat { font-size: 12px; color: #8b949e; }
.stat b { color: #e6edf3; font-size: 13px; }
.stat .ok { color: #3fb950; } .stat .bad { color: #f85149; }
.controls { margin-left: auto; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
label { font-size: 12px; color: #8b949e; display: flex; gap: 6px; align-items: center; }
select { background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 4px 8px; }
input[type=range] { accent-color: #58a6ff; }
input[type=text] { background:#0d1117; color:#e6edf3; border:1px solid #30363d; border-radius:6px; padding:4px 8px; width:130px; }
.toggle button { background:#0d1117; color:#8b949e; border:1px solid #30363d; padding:4px 10px; cursor:pointer; font-size:12px; }
.toggle button:first-child { border-radius:6px 0 0 6px; } .toggle button:last-child { border-radius:0 6px 6px 0; border-left:0; }
.toggle button.on { background:#1f6feb; color:#fff; border-color:#1f6feb; }
main { padding: 16px 18px; }
.grid { display: grid; grid-template-columns: repeat(var(--cols), minmax(0,1fr)); gap: 10px; }
/* card view */
.card { background: #161b22; border: 1px solid #30363d; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; overflow: hidden; }
.card.online { border-left-color: #3fb950; } .card.errored { border-left-color: #f85149; }
.card.stopped, .card.unknown { border-left-color: #d29922; }
.name { font-weight: 600; font-size: 13.5px; word-break: break-all; margin-bottom: 4px; }
.status { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
.online .status { color: #3fb950; } .errored .status { color: #f85149; }
.stopped .status, .unknown .status { color: #d29922; }
.ports { margin: 6px 0; display: flex; gap: 5px; flex-wrap: wrap; }
.port { font: 11px ui-monospace, Menlo, monospace; background: #1f6feb22; color: #58a6ff; border: 1px solid #1f6feb55; border-radius: 5px; padding: 1px 6px; }
.port.cfg { background: #6e768122; color: #8b949e; border-color: #6e768155; }
.meta { font-size: 11px; color: #8b949e; display: flex; justify-content: space-between; margin-top: 6px; }
.when { font-size: 11px; color: #6e7681; margin-top: 4px; }
/* frame view */
.frame { background:#161b22; border:1px solid #30363d; border-radius:8px; overflow:hidden; display:flex; flex-direction:column; height: 300px; }
.frame .fhead { display:flex; align-items:center; gap:6px; padding:6px 8px; font-size:11px; border-bottom:1px solid #30363d; background:#0d1117; }
.frame .fname { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:#e6edf3; }
.frame .badge { font-size:9px; padding:1px 5px; border-radius:4px; text-transform:uppercase; letter-spacing:.4px; }
.badge.local { background:#a371f722; color:#a371f7; border:1px solid #a371f755; }
.badge.remote { background:#1f6feb22; color:#58a6ff; border:1px solid #1f6feb55; }
.frame .fopen { margin-left:auto; color:#58a6ff; text-decoration:none; }
.frame .fwrap { flex:1; position:relative; background:#fff; }
.frame iframe { position:absolute; inset:0; width:100%; height:100%; border:0; }
.frame .fnote { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; text-align:center;
color:#6e7681; font-size:11px; padding:10px; pointer-events:none; }
.orphans { margin-top: 22px; padding: 12px; border: 1px dashed #d29922; border-radius: 8px; background:#1c1809; }
.orphans h2 { font-size: 13px; margin: 0 0 8px; color: #d29922; }
.muted { color: #6e7681; }
</style>
</head>
<body>
<header>
<h1>🛰 PM2 Fleet</h1>
<span class="stat">host <b id="host">…</b></span>
<span class="stat">procs <b id="total">…</b></span>
<span class="stat">online <b class="ok" id="online">…</b></span>
<span class="stat">down <b class="bad" id="down">…</b></span>
<span class="stat">remote ports <b id="ports">…</b></span>
<span class="stat">local <b id="localct">…</b></span>
<span class="stat muted" id="fetched"></span>
<div class="controls">
<span class="toggle" id="view"><button data-v="cards">Cards</button><button data-v="frames">Frames</button></span>
<input type="text" id="filter" placeholder="filter…">
<label>sort
<select id="sort">
<option value="newest">Newest</option>
<option value="name">Name A→Z</option>
<option value="port">Port</option>
<option value="status">Status</option>
<option value="restarts">Restarts ↓</option>
<option value="mem">Memory ↓</option>
</select>
</label>
<label>density <input type="range" id="density" min="1" max="8" step="1" value="4"></label>
</div>
</header>
<main>
<div class="grid" id="grid"></div>
<div class="orphans" id="orphans" style="display:none"><h2>⚠ Listening ports owned by NO pm2 process</h2><div id="orphanList"></div></div>
</main>
<script>
const $ = s => document.querySelector(s);
let DATA = null;
const sortSel=$('#sort'), densitySel=$('#density'), filterEl=$('#filter');
let VIEW = localStorage.getItem('pfv-view') || 'cards';
sortSel.value = localStorage.getItem('pfv-sort') || 'newest';
densitySel.value = localStorage.getItem('pfv-density') || '4';
document.documentElement.style.setProperty('--cols', densitySel.value);
function fmtWhen(ms){ return ms? new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '—'; }
function uptime(ms){ if(!ms) return ''; const s=(Date.now()-ms)/1000; return s<3600?Math.floor(s/60)+'m':s<86400?Math.floor(s/3600)+'h':Math.floor(s/86400)+'d'; }
function setView(v){ VIEW=v; localStorage.setItem('pfv-view',v);
document.querySelectorAll('#view button').forEach(b=>b.classList.toggle('on', b.dataset.v===v)); render(); }
function sortList(arr){
const sort=sortSel.value, rank={errored:0,stopped:1,unknown:2,online:3};
return arr.sort((a,b)=>{ switch(sort){
case 'name': return (a.name||'').localeCompare(b.name||'');
case 'port': return (a.primaryPort||a.port||99999)-(b.primaryPort||b.port||99999);
case 'status': return (rank[a.status]??9)-(rank[b.status]??9);
case 'restarts': return (b.restarts||0)-(a.restarts||0);
case 'mem': return (b.memMB||0)-(a.memMB||0);
default: return (b.startedAt||0)-(a.startedAt||0); } });
}
function renderCards(){
const q=filterEl.value.trim().toLowerCase();
let procs=DATA.procs.filter(p=>!q||p.name.toLowerCase().includes(q)||String(p.configuredPort||'').includes(q)||p.boundPorts.some(b=>String(b).includes(q)));
procs=sortList(procs);
$('#grid').innerHTML=procs.map(p=>{
const bound=p.boundPorts.map(b=>`<span class="port" title="listening">${b}</span>`).join('');
const cfg=(p.configuredPort&&!p.boundPorts.includes(p.configuredPort))?`<span class="port cfg">cfg ${p.configuredPort}</span>`:'';
const noport=(!p.boundPorts.length&&!p.configuredPort)?'<span class="port cfg">no port</span>':'';
const dom=p.domain?`<span class="muted"> · ${p.domain}</span>`:'';
return `<div class="card ${p.status}"><div class="name" title="${p.cwd}">${p.name}</div>
<div class="status">${p.status}${p.restarts>20?` · ${p.restarts}⟳`:''}${dom}</div>
<div class="ports">${bound}${cfg}${noport}</div>
<div class="meta"><span>pid ${p.pid||'—'}</span><span>${p.memMB}MB · ${p.cpu}%</span></div>
<div class="when" title="${p.startedAt?new Date(p.startedAt).toISOString():''}">🕓 ${fmtWhen(p.startedAt)} <span class="muted">(up ${uptime(p.startedAt)})</span></div></div>`;
}).join('');
}
function renderFrames(){
const q=filterEl.value.trim().toLowerCase();
// build a single list of frameable targets: remote procs (with url) + local ports
const remote=DATA.procs.filter(p=>p.url).map(p=>({name:p.name,url:p.url,port:p.primaryPort,primaryPort:p.primaryPort,kind:'remote',sub:p.domain||p.url,status:p.status,startedAt:p.startedAt,restarts:p.restarts,memMB:p.memMB}));
const local=(DATA.local||[]).map(l=>({name:l.command,url:l.url,port:l.port,primaryPort:l.port,kind:'local',sub:`localhost:${l.port}`,status:'online'}));
let all=remote.concat(local).filter(t=>!q||t.name.toLowerCase().includes(q)||String(t.port||'').includes(q)||(t.sub||'').toLowerCase().includes(q));
all=sortList(all);
$('#grid').innerHTML=all.map(t=>`<div class="frame">
<div class="fhead"><span class="badge ${t.kind}">${t.kind}</span>
<span class="fname" title="${t.sub}">${t.name}:${t.port||'?'}</span>
<a class="fopen" href="${t.url}" target="_blank" rel="noopener" title="open in new tab">↗</a></div>
<div class="fwrap"><div class="fnote">loading ${t.sub}…<br><span class="muted">(blank = site blocks embedding — use ↗)</span></div>
<iframe src="${t.url}" loading="lazy" referrerpolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-forms"></iframe></div>
</div>`).join('');
}
function render(){
if(!DATA) return;
$('#host').textContent=DATA.host; $('#total').textContent=DATA.total;
$('#online').textContent=DATA.online; $('#down').textContent=DATA.notOnline;
$('#ports').textContent=DATA.listeningCount; $('#localct').textContent=(DATA.local||[]).length;
$('#fetched').textContent='updated '+new Date(DATA.fetchedAt).toLocaleTimeString();
document.querySelectorAll('#view button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
if(VIEW==='frames') renderFrames(); else renderCards();
const orphans=DATA.orphanPorts||[];
$('#orphans').style.display=(VIEW==='cards'&&orphans.length)?'block':'none';
$('#orphanList').innerHTML=orphans.map(p=>`<span class="port">${p}</span>`).join(' ');
}
async function load(){ try{ DATA=await (await fetch('/api/fleet')).json(); render(); }catch(e){ $('#fetched').textContent='fetch error'; } }
document.querySelectorAll('#view button').forEach(b=>b.onclick=()=>setView(b.dataset.v));
sortSel.onchange=()=>{ localStorage.setItem('pfv-sort',sortSel.value); render(); };
densitySel.oninput=()=>{ localStorage.setItem('pfv-density',densitySel.value); document.documentElement.style.setProperty('--cols',densitySel.value); render(); };
filterEl.oninput=render;
setView(VIEW);
load(); setInterval(load, 15000);
</script>
</body>
</html>