← 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)`));