← back to Pm2 Fleet Viewer
pm2-fleet-viewer: live grid of all pm2 procs + ports (sort/density/timestamps)
92e95a750f5d33f956f1455a64b9efcfd204a695 · 2026-06-22 17:10:53 -0700 · Steve Abrams
Files touched
A .gitignoreA package.jsonA public/index.htmlA server.js
Diff
commit 92e95a750f5d33f956f1455a64b9efcfd204a695
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 22 17:10:53 2026 -0700
pm2-fleet-viewer: live grid of all pm2 procs + ports (sort/density/timestamps)
---
.gitignore | 5 ++
package.json | 7 +++
public/index.html | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 110 ++++++++++++++++++++++++++++++++++++
4 files changed, 285 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b38eead
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+*.log
+.DS_Store
+tmp/
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..65cb876
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "pm2-fleet-viewer",
+ "version": "1.0.0",
+ "description": "Live grid of all pm2 processes + ports on the Kamatera fleet",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..259ecb0
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,163 @@
+<!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: 18px; 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; }
+ 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; }
+ 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.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; }
+ .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">ports <b id="ports">…</b></span>
+ <span class="stat muted" id="fetched"></span>
+ <div class="controls">
+ <input type="text" id="filter" placeholder="filter name/port…">
+ <label>sort
+ <select id="sort">
+ <option value="newest">Newest (started)</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="2" 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');
+
+// persist controls (standing rule)
+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 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();
+
+ 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
+ }
+ });
+
+ $('#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>
+ <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('');
+
+ const orphans = DATA.orphanPorts || [];
+ $('#orphans').style.display = orphans.length ? 'block' : 'none';
+ $('#orphanList').innerHTML = orphans.map(p => `<span class="port">${p}</span>`).join(' ');
+}
+
+async function load() {
+ try {
+ const r = await fetch('/api/fleet');
+ DATA = await r.json();
+ render();
+ } catch (e) { $('#fetched').textContent = 'fetch error'; }
+}
+
+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;
+
+load();
+setInterval(load, 10000); // live refresh
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..309e53d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,110 @@
+#!/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).
+ *
+ * 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).
+ *
+ * PORT=9843 node server.js # then open http://localhost:9843
+ */
+const http = require('http');
+const { execFile } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = Number(process.env.PORT || 9843);
+const HOST = process.env.FLEET_SSH_HOST || 'my-server';
+const CACHE_MS = 8000;
+let cache = { at: 0, data: null };
+
+function ssh(remoteCmd) {
+ return new Promise((resolve) => {
+ execFile('ssh', [HOST, remoteCmd], { maxBuffer: 64 << 20, timeout: 25000 }, (err, stdout) => {
+ resolve(stdout || '');
+ });
+ });
+}
+
+// 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 pidM = line.match(/pid=(\d+)/);
+ if (portM) rows.push({ port: Number(portM[1]), pid: pidM ? Number(pidM[1]) : null });
+ }
+ return rows;
+}
+
+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')]);
+ let procs = [];
+ try { procs = JSON.parse(jlistRaw); } catch { procs = []; }
+ const listening = parseSS(ssRaw);
+ const byPid = {};
+ for (const l of listening) if (l.pid) (byPid[l.pid] ||= []).push(l.port);
+
+ const ownedPids = new Set();
+ const out = procs.map(p => {
+ const e = p.pm2_env || {};
+ const pid = p.pid || null;
+ 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) : [];
+ 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,
+ memMB: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1048576) : 0,
+ cwd: e.pm_cwd || '',
+ configuredPort: configuredPort ? Number(configuredPort) : null,
+ boundPorts,
+ };
+ });
+ // 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 data = {
+ fetchedAt: new Date().toISOString(),
+ host: HOST,
+ 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),
+ };
+ cache = { at: Date.now(), data };
+ return data;
+}
+
+const server = http.createServer(async (req, res) => {
+ if (req.url.startsWith('/api/fleet')) {
+ try {
+ const data = await getFleet();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(data));
+ } catch (e) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: String(e.message || e) }));
+ }
+ return;
+ }
+ const file = path.join(__dirname, 'public', 'index.html');
+ fs.readFile(file, (err, buf) => {
+ if (err) { res.writeHead(404); res.end('not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(buf);
+ });
+});
+
+server.listen(PORT, () => console.log(`pm2-fleet-viewer → http://localhost:${PORT} (fleet host: ${HOST})`));
(oldest)
·
back to Pm2 Fleet Viewer
·
iframe-wall view: live previews of all remote (domain-mapped 2843018 →