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