← 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
M public/index.htmlM server.js
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)