← back to Port Viewer

grid.js

179 lines

#!/usr/bin/env node
/**
 * grid.js — live grid of every LOCAL web service, each shown as an iframe of its
 * front page. Scans listening TCP ports, HTTP-probes which are real web pages,
 * labels them with the pm2 process name, and renders a density-adjustable grid.
 * Separate from the 8888 server-monitor (server.js) — this is the localhost grid.
 *   PORT=9794 node grid.js
 */
const http = require('http');
const { execSync } = require('child_process');

const PORT = parseInt(process.env.PORT || '9794', 10);

function listeningPorts() {
  let out = '';
  for (let i = 0; i < 3 && !out; i++) {   // retry — machine is fork-starved, lsof EAGAINs
    try { out = execSync('lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null', { encoding: 'utf8', maxBuffer: 8 << 20 }); }
    catch { out = ''; }
  }
  if (!out) return [];
  const seen = new Map();
  for (const line of out.split('\n').slice(1)) {
    const c = line.trim().split(/\s+/);
    if (c.length < 9) continue;
    const m = (c[8] || '').match(/(?:127\.0\.0\.1|\*|\[::1?\]|localhost):(\d+)$/);
    if (!m) continue;
    const p = parseInt(m[1], 10);
    if (!seen.has(p)) seen.set(p, { proc: c[0], pid: parseInt(c[1], 10) });
  }
  return [...seen.entries()].map(([port, v]) => ({ port, proc: v.proc, pid: v.pid })).sort((a, b) => a.port - b.port);
}

// pm2 jlist HANGS on Mac2 — read the saved dump file instead (no CLI).
function pm2Names() {
  const map = {};
  try {
    const fs = require('fs');
    const dump = JSON.parse(fs.readFileSync(require('os').homedir() + '/.pm2/dump.pm2', 'utf8'));
    for (const p of dump) {
      const env = p.pm2_env || p.env || {};
      const port = env.PORT || env.env?.PORT || (p.args && String(p.args).match(/\b(\d{4,5})\b/)?.[1]);
      if (port && p.name) map[port] = p.name;
    }
  } catch {}
  return map;
}

function probe(port) {
  return new Promise((resolve) => {
    const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 2500, headers: { 'User-Agent': 'port-viewer' } }, (res) => {
      const xfo = (res.headers['x-frame-options'] || '').toLowerCase();
      res.resume();
      // any HTTP response (even 401/404) means it's a web service worth showing
      resolve({ ok: res.statusCode < 500, status: res.statusCode, frameable: !/deny|sameorigin/.test(xfo) });
    });
    req.on('error', () => resolve(null));
    req.on('timeout', () => { req.destroy(); resolve(null); });
  });
}

// run probes with bounded concurrency so 148 ports don't exhaust sockets
async function pool(items, n, fn) {
  const out = []; let i = 0;
  await Promise.all(Array.from({ length: n }, async () => {
    while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx]); }
  }));
  return out;
}

async function buildList() {
  const ports = listeningPorts().filter(p => p.port !== PORT && ![5432, 7205, 22, 25, 53, 111, 123, 631].includes(p.port));
  const names = pm2Names();
  const checked = await pool(ports, 16, async ({ port, proc, pid }) => {
    const pr = await probe(port);
    if (!pr || !pr.ok) return null;
    return { port, proc, pid, name: names[port] || proc, status: pr.status, frameable: pr.frameable };
  });
  return checked.filter(Boolean);
}

const PAGE = (items) => `<!doctype html><html><head><meta charset="utf-8">
<title>Port Grid · ${items.length} services</title>
<style>
  :root{--cols:4}
  *{box-sizing:border-box} body{margin:0;background:#0d1117;color:#e6edf3;font:13px/1.4 -apple-system,system-ui,sans-serif}
  header{position:sticky;top:0;z-index:5;display:flex;gap:16px;align-items:center;padding:10px 16px;background:#161b22;border-bottom:1px solid #30363d}
  header h1{font-size:15px;margin:0;font-weight:600}.muted{color:#8b949e}
  header label{display:flex;gap:7px;align-items:center;color:#8b949e;font-size:12px}
  input[type=range]{width:160px}
  input[type=search]{background:#0d1117;border:1px solid #30363d;color:#e6edf3;border-radius:6px;padding:5px 9px;width:180px}
  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px;padding:14px}
  .card{background:#161b22;border:1px solid #30363d;border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
  .bar{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #30363d;font-size:12px}
  .bar b{color:#58a6ff}.bar .nm{color:#e6edf3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .bar a{margin-left:auto;color:#8b949e;text-decoration:none;font-size:11px;border:1px solid #30363d;border-radius:5px;padding:2px 7px}
  .bar a:hover{color:#58a6ff;border-color:#58a6ff}
  .frame{position:relative;aspect-ratio:16/10;background:#0d1117}
  iframe{border:0;width:100%;height:100%;background:#fff}
  .noframe{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;align-items:center;justify-content:center;color:#8b949e;font-size:12px;text-align:center;padding:12px}
  .hidden{display:none}
</style></head><body>
<header>
  <h1>🔌 Port Grid <span class="muted">· ${items.length} live web services</span></h1>
  <label>density <input type="range" id="cols" min="1" max="8" value="4"></label>
  <input type="search" id="q" placeholder="filter port / name…">
  <button id="restartAll" style="margin-left:auto;background:#b22;border:1px solid #d44;color:#fff;border-radius:6px;padding:6px 12px;cursor:pointer;font-size:12px">⟳ Restart All</button>
</header>
<div class="grid" id="grid">
${items.map(it => `  <div class="card" data-k="${it.port} ${(it.name||'').toLowerCase()}">
    <div class="bar"><b>:${it.port}</b><span class="nm" title="${it.name}">${it.name}</span>
      <button class="rs" data-port="${it.port}" title="restart :${it.port}" style="margin-left:auto;background:#21262d;border:1px solid #30363d;color:#e6edf3;border-radius:5px;padding:2px 7px;cursor:pointer;font-size:11px">⟳</button>
      <a href="http://localhost:${it.port}" target="_blank">open ↗</a></div>
    <div class="frame">${it.frameable
      ? `<iframe loading="lazy" src="http://localhost:${it.port}/"></iframe>`
      : `<div class="noframe">blocks embedding (X-Frame-Options)<a href="http://localhost:${it.port}" target="_blank" style="color:#58a6ff">open :${it.port} ↗</a></div>`}</div>
  </div>`).join('\n')}
</div>
<script>
  const cols=document.getElementById('cols'), root=document.documentElement;
  const sc=localStorage.getItem('pv_cols'); if(sc){cols.value=sc;root.style.setProperty('--cols',sc);}
  cols.oninput=()=>{root.style.setProperty('--cols',cols.value);localStorage.setItem('pv_cols',cols.value);};
  document.getElementById('q').oninput=e=>{const v=e.target.value.toLowerCase();
    document.querySelectorAll('.card').forEach(c=>c.classList.toggle('hidden',v&&!c.dataset.k.includes(v)));};
  function rs(port,btn){btn.textContent='…';fetch('/api/restart?port='+port,{method:'POST'}).then(r=>r.json()).then(j=>{btn.textContent=j.ok?'✓':'✗';btn.title=j.msg||'';setTimeout(()=>btn.textContent='⟳',2500);});}
  document.querySelectorAll('.rs').forEach(b=>b.onclick=()=>rs(b.dataset.port,b));
  document.getElementById('restartAll').onclick=function(){
    if(!confirm('Restart ALL ${items.length} services? Brief downtime — pm2 respawns them.'))return;
    this.textContent='restarting…';
    fetch('/api/restart-all',{method:'POST'}).then(r=>r.json()).then(j=>{
      const ok=j.filter(x=>x.ok).length;alert('Sent restart to '+ok+'/'+j.length+' services. Reloading in 6s…');
      setTimeout(()=>location.reload(),6000);});};
  setTimeout(()=>location.reload(),60000);
</script></body></html>`;

// cache: rebuild in the background every 30s; serve cached instantly
let CACHE = [];
let lastScan = null;
let building = false;
async function refresh() {
  if (building) return; building = true;
  try { const r = await buildList(); if (r.length) { CACHE = r; lastScan = new Date().toISOString(); } } // keep last-good on transient fork failure
  catch (e) { console.error('scan error', e.message); }
  building = false;
}
refresh(); setInterval(refresh, 45000);

// restart a service by killing its current listener PID; pm2's God daemon
// (alive, autorestart=true) respawns it. CLI is wedged so we can't use `pm2 restart`.
function killPort(port) {
  try {
    const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t 2>/dev/null`, { encoding: 'utf8', timeout: 3000 }).trim();
    const pids = [...new Set(out.split('\n').filter(Boolean))];
    if (!pids.length) return { ok: false, port, msg: 'no listener' };
    pids.forEach(pid => { try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch {} });
    return { ok: true, port, msg: `SIGTERM pid ${pids.join(',')} (pm2 respawns)` };
  } catch (e) { return { ok: false, port, msg: e.message }; }
}

http.createServer((req, res) => {
  const u = req.url;
  if (u.startsWith('/api/ports')) {
    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(CACHE));
  }
  if (u.startsWith('/api/restart-all') && req.method === 'POST') {
    const results = CACHE.filter(it => it.port !== PORT).map(it => killPort(it.port));
    setTimeout(refresh, 4000);
    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(results));
  }
  const rm = u.match(/^\/api\/restart\?port=(\d+)/);
  if (rm && req.method === 'POST') {
    const p = parseInt(rm[1], 10);
    const r = p === PORT ? { ok: false, msg: 'refusing to restart the viewer itself' } : killPort(p);
    setTimeout(refresh, 4000);
    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(r));
  }
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end(PAGE(CACHE));
}).listen(PORT, '127.0.0.1', () => console.log(`[port-grid] http://localhost:${PORT}`));