[object Object]

← 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

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 →