← back to Pm2 Fleet Viewer
server.js
140 lines
#!/usr/bin/env node
/**
* 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).
*
* - `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
*/
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 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 run(cmd, args, timeout = 25000) {
return new Promise((resolve) => {
execFile(cmd, args, { maxBuffer: 64 << 20, timeout }, (err, stdout) => resolve(stdout || ''));
});
}
const ssh = (remoteCmd) => run('ssh', [HOST, remoteCmd]);
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;
}
// 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, 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);
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) : [];
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,
memMB: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1048576) : 0,
cwd: e.pm_cwd || '', configuredPort: configuredPort ? Number(configuredPort) : null,
boundPorts, primaryPort, domain, url,
};
});
const orphanPorts = listening.filter(l => !l.pid || !ownedPids.has(l.pid)).map(l => l.port);
const data = {
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),
local,
};
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}, local lsof)`));