[object Object]

← back to Pm2 Fleet Viewer

iframe-wall view: live previews of all remote (domain-mapped) + local ports

284301844d031206acce0489034fe7e59faaa860 · 2026-06-22 17:15:38 -0700 · Steve Abrams

Files touched

Diff

commit 284301844d031206acce0489034fe7e59faaa860
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 22 17:15:38 2026 -0700

    iframe-wall view: live previews of all remote (domain-mapped) + local ports
---
 public/index.html | 175 ++++++++++++++++++++++++++++--------------------------
 server.js         |  97 +++++++++++++++++++-----------
 2 files changed, 154 insertions(+), 118 deletions(-)

diff --git a/public/index.html b/public/index.html
index 259ecb0..a3f929c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -10,33 +10,46 @@
   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: 18px; align-items: center; flex-wrap: wrap; }
+           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: 14px; align-items: center; }
+  .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, input[type=range] { accent-color: #58a6ff; }
   select { background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 4px 8px; }
-  input[type=text] { background:#0d1117; color:#e6edf3; border:1px solid #30363d; border-radius:6px; padding:4px 8px; width:140px; }
+  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 { 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 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 { 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; }
@@ -49,13 +62,15 @@
   <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">ports <b id="ports">…</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">
-    <input type="text" id="filter" placeholder="filter name/port…">
+    <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 (started)</option>
+        <option value="newest">Newest</option>
         <option value="name">Name A→Z</option>
         <option value="port">Port</option>
         <option value="status">Status</option>
@@ -63,101 +78,93 @@
         <option value="mem">Memory ↓</option>
       </select>
     </label>
-    <label>density <input type="range" id="density" min="2" max="8" step="1" value="4"></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>
+  <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');
-
-// persist controls (standing rule)
+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) {
-  if (!ms) return '—';
-  return 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;
-  if (s < 3600) return Math.floor(s/60) + 'm';
-  if (s < 86400) return Math.floor(s/3600) + 'h';
-  return Math.floor(s/86400) + 'd';
-}
+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 render() {
-  if (!DATA) return;
-  $('#host').textContent = DATA.host;
-  $('#total').textContent = DATA.total;
-  $('#online').textContent = DATA.online;
-  $('#down').textContent = DATA.notOnline;
-  $('#ports').textContent = DATA.listeningCount;
-  $('#fetched').textContent = 'updated ' + new Date(DATA.fetchedAt).toLocaleTimeString();
+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(); }
 
-  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)));
-
-  const sort = sortSel.value;
-  const statusRank = { errored:0, stopped:1, unknown:2, online:3 };
-  procs.sort((a,b) => {
-    switch (sort) {
-      case 'name': return a.name.localeCompare(b.name);
-      case 'port': return (a.configuredPort||a.boundPorts[0]||99999) - (b.configuredPort||b.boundPorts[0]||99999);
-      case 'status': return (statusRank[a.status]??9) - (statusRank[b.status]??9);
-      case 'restarts': return b.restarts - a.restarts;
-      case 'mem': return b.memMB - a.memMB;
-      default: return (b.startedAt||0) - (a.startedAt||0); // newest
-    }
-  });
+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); } });
+}
 
-  $('#grid').innerHTML = procs.map(p => {
-    const bound = p.boundPorts.map(b => `<span class="port" title="actually listening">${b}</span>`).join('');
-    const cfg = (p.configuredPort && !p.boundPorts.includes(p.configuredPort))
-      ? `<span class="port cfg" title="configured PORT (not currently bound)">cfg ${p.configuredPort}</span>` : '';
-    const noport = (!p.boundPorts.length && !p.configuredPort) ? '<span class="port cfg">no port</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}⟳`:''}</div>
+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>`;
+      <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('');
+}
 
-  const orphans = DATA.orphanPorts || [];
-  $('#orphans').style.display = orphans.length ? 'block' : 'none';
-  $('#orphanList').innerHTML = orphans.map(p => `<span class="port">${p}</span>`).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('');
 }
 
-async function load() {
-  try {
-    const r = await fetch('/api/fleet');
-    DATA = await r.json();
-    render();
-  } catch (e) { $('#fetched').textContent = 'fetch error'; }
+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(' ');
 }
 
-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;
+async function load(){ try{ DATA=await (await fetch('/api/fleet')).json(); render(); }catch(e){ $('#fetched').textContent='fetch error'; } }
 
-load();
-setInterval(load, 10000); // live refresh
+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>
diff --git a/server.js b/server.js
index 309e53d..53ea948 100644
--- a/server.js
+++ b/server.js
@@ -1,13 +1,13 @@
 #!/usr/bin/env node
 /**
- * pm2-fleet-viewer — live grid of every pm2 process on the Kamatera fleet host
- * and all the ports they bind. Zero-dependency (Node built-in http only).
+ * pm2-fleet-viewer — live grid + iframe wall of every port on the Kamatera fleet
+ * AND this local Mac. Zero-dependency (Node built-in http only).
  *
- * Reads live data by ssh-ing the fleet host (alias `my-server`) each refresh:
- *   - `pm2 jlist`  → process name/status/uptime/restarts/cpu/mem + configured PORT
- *   - `ss -ltnp`   → every actually-LISTENING port, mapped back to its pid
- * It then joins the two so each card shows BOTH the configured port and the
- * real bound port(s), and surfaces orphan ports (listening but owned by no pm2 proc).
+ *   - `pm2 jlist`  (remote) → process name/status/uptime/restarts/cpu/mem + PORT
+ *   - `ss -ltnp`   (remote) → every LISTENING port mapped to its pid
+ *   - nginx sites-enabled (remote) → port→public-domain map, so remote iframes load
+ *      over https://<domain> (firewall blocks raw IP:port, nginx:443 does not)
+ *   - `lsof` (local) → every LISTENING port on this Mac → http://localhost:PORT
  *
  *   PORT=9843 node server.js     # then open http://localhost:9843
  */
@@ -18,31 +18,67 @@ const path = require('path');
 
 const PORT = Number(process.env.PORT || 9843);
 const HOST = process.env.FLEET_SSH_HOST || 'my-server';
+const PUBLIC_IP = process.env.FLEET_PUBLIC_IP || '45.61.58.125';
 const CACHE_MS = 8000;
 let cache = { at: 0, data: null };
+let nginxMap = { at: 0, map: {} }; // port -> domain (cached 5 min)
 
-function ssh(remoteCmd) {
+function run(cmd, args, timeout = 25000) {
   return new Promise((resolve) => {
-    execFile('ssh', [HOST, remoteCmd], { maxBuffer: 64 << 20, timeout: 25000 }, (err, stdout) => {
-      resolve(stdout || '');
-    });
+    execFile(cmd, args, { maxBuffer: 64 << 20, timeout }, (err, stdout) => resolve(stdout || ''));
   });
 }
+const ssh = (remoteCmd) => run('ssh', [HOST, remoteCmd]);
 
-// parse `ss -ltnp` → [{port, pid}]
 function parseSS(text) {
   const rows = [];
   for (const line of text.split('\n')) {
-    const portM = line.match(/[\d.*\]:]:(\d{2,5})\s/) || line.match(/:(\d{2,5})\s+\S+\s+users:/);
+    const portM = line.match(/[\d.*\]]:(\d{2,5})\s/) || line.match(/:(\d{2,5})\s+\S+\s+users:/);
     const pidM = line.match(/pid=(\d+)/);
     if (portM) rows.push({ port: Number(portM[1]), pid: pidM ? Number(pidM[1]) : null });
   }
   return rows;
 }
 
+// remote nginx: port -> primary domain
+async function getNginxMap() {
+  if (Date.now() - nginxMap.at < 300000 && Object.keys(nginxMap.map).length) return nginxMap.map;
+  const raw = await ssh(
+    `for f in /etc/nginx/sites-enabled/*; do ` +
+    `dom=$(grep -hm1 server_name "$f" 2>/dev/null | sed 's/.*server_name//; s/[;{].*//' | awk '{print $1}'); ` +
+    `port=$(grep -hoE 'proxy_pass http://127.0.0.1:[0-9]+' "$f" 2>/dev/null | grep -oE '[0-9]+$' | head -1); ` +
+    `[ -n "$port" ] && [ -n "$dom" ] && echo "$port $dom"; done`
+  );
+  const map = {};
+  for (const line of raw.split('\n')) {
+    const m = line.trim().match(/^(\d+)\s+(\S+)$/);
+    if (m && m[2] !== '_' && !map[m[1]]) map[m[1]] = m[2];
+  }
+  nginxMap = { at: Date.now(), map };
+  return map;
+}
+
+// local Mac listening ports
+async function getLocalPorts() {
+  const raw = await run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], 8000);
+  const seen = {};
+  for (const line of raw.split('\n').slice(1)) {
+    const cols = line.split(/\s+/);
+    if (cols.length < 9) continue;
+    const cmd = cols[0], pid = cols[1], addr = cols[8];
+    const pm = addr && addr.match(/:(\d{2,5})$/);
+    if (!pm) continue;
+    const port = Number(pm[1]);
+    if (!seen[port]) seen[port] = { port, command: cmd, pid: Number(pid), url: `http://localhost:${port}` };
+  }
+  return Object.values(seen).sort((a, b) => a.port - b.port);
+}
+
 async function getFleet() {
   if (cache.data && Date.now() - cache.at < CACHE_MS) return cache.data;
-  const [jlistRaw, ssRaw] = await Promise.all([ssh('pm2 jlist'), ssh('ss -ltnp 2>/dev/null')]);
+  const [jlistRaw, ssRaw, nmap, local] = await Promise.all([
+    ssh('pm2 jlist'), ssh('ss -ltnp 2>/dev/null'), getNginxMap(), getLocalPorts(),
+  ]);
   let procs = [];
   try { procs = JSON.parse(jlistRaw); } catch { procs = []; }
   const listening = parseSS(ssRaw);
@@ -56,32 +92,25 @@ async function getFleet() {
     if (pid) ownedPids.add(pid);
     const configuredPort = (e.env && e.env.PORT) || e.PORT || null;
     const boundPorts = (pid && byPid[pid]) ? [...new Set(byPid[pid])].sort((a, b) => a - b) : [];
+    const primaryPort = boundPorts[0] || (configuredPort ? Number(configuredPort) : null);
+    const domain = primaryPort && nmap[String(primaryPort)] ? nmap[String(primaryPort)] : null;
+    const url = domain ? `https://${domain}` : (primaryPort ? `http://${PUBLIC_IP}:${primaryPort}` : null);
     return {
-      name: p.name,
-      pid,
-      status: e.status || 'unknown',
-      startedAt: e.pm_uptime || null,
-      restarts: e.restart_time || 0,
-      cpu: (p.monit && p.monit.cpu) || 0,
+      name: p.name, pid, status: e.status || 'unknown', startedAt: e.pm_uptime || null,
+      restarts: e.restart_time || 0, cpu: (p.monit && p.monit.cpu) || 0,
       memMB: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1048576) : 0,
-      cwd: e.pm_cwd || '',
-      configuredPort: configuredPort ? Number(configuredPort) : null,
-      boundPorts,
+      cwd: e.pm_cwd || '', configuredPort: configuredPort ? Number(configuredPort) : null,
+      boundPorts, primaryPort, domain, url,
     };
   });
-  // ports that are listening but belong to no pm2 process
-  const orphanPorts = listening
-    .filter(l => !l.pid || !ownedPids.has(l.pid))
-    .map(l => l.port);
+  const orphanPorts = listening.filter(l => !l.pid || !ownedPids.has(l.pid)).map(l => l.port);
   const data = {
-    fetchedAt: new Date().toISOString(),
-    host: HOST,
-    total: out.length,
-    online: out.filter(p => p.status === 'online').length,
+    fetchedAt: new Date().toISOString(), host: HOST, publicIp: PUBLIC_IP,
+    total: out.length, online: out.filter(p => p.status === 'online').length,
     notOnline: out.filter(p => p.status !== 'online').length,
     listeningCount: new Set(listening.map(l => l.port)).size,
-    procs: out,
-    orphanPorts: [...new Set(orphanPorts)].sort((a, b) => a - b),
+    procs: out, orphanPorts: [...new Set(orphanPorts)].sort((a, b) => a - b),
+    local,
   };
   cache = { at: Date.now(), data };
   return data;
@@ -107,4 +136,4 @@ const server = http.createServer(async (req, res) => {
   });
 });
 
-server.listen(PORT, () => console.log(`pm2-fleet-viewer → http://localhost:${PORT}  (fleet host: ${HOST})`));
+server.listen(PORT, () => console.log(`pm2-fleet-viewer → http://localhost:${PORT}  (fleet: ${HOST}, local lsof)`));

← 92e95a7 pm2-fleet-viewer: live grid of all pm2 procs + ports (sort/d  ·  back to Pm2 Fleet Viewer  ·  (newest)