← 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>