← back to Port Viewer
Port grid viewer: iframe-per-service grid + per-port & restart-all buttons; fork-starvation resilient (retry lsof, keep last-good cache)
dfc290b6288fc12c9c716266dc5681166b167069 · 2026-06-11 23:08:31 -0700 · Steve Abrams
Files touched
Diff
commit dfc290b6288fc12c9c716266dc5681166b167069
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jun 11 23:08:31 2026 -0700
Port grid viewer: iframe-per-service grid + per-port & restart-all buttons; fork-starvation resilient (retry lsof, keep last-good cache)
---
grid.js | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++---------
1 file changed, 53 insertions(+), 9 deletions(-)
diff --git a/grid.js b/grid.js
index fe34890..65f75d4 100644
--- a/grid.js
+++ b/grid.js
@@ -13,7 +13,11 @@ const PORT = parseInt(process.env.PORT || '9794', 10);
function listeningPorts() {
let out = '';
- try { out = execSync('lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null', { encoding: 'utf8' }); } catch { return []; }
+ 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+/);
@@ -21,9 +25,9 @@ function listeningPorts() {
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, c[0]);
+ if (!seen.has(p)) seen.set(p, { proc: c[0], pid: parseInt(c[1], 10) });
}
- return [...seen.entries()].map(([port, proc]) => ({ port, proc })).sort((a, b) => a.port - b.port);
+ 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).
@@ -66,10 +70,10 @@ async function pool(items, n, fn) {
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 }) => {
+ const checked = await pool(ports, 16, async ({ port, proc, pid }) => {
const pr = await probe(port);
if (!pr || !pr.ok) return null;
- return { port, proc, name: names[port] || proc, status: pr.status, frameable: pr.frameable };
+ return { port, proc, pid, name: names[port] || proc, status: pr.status, frameable: pr.frameable };
});
return checked.filter(Boolean);
}
@@ -99,11 +103,12 @@ const PAGE = (items) => `<!doctype html><html><head><meta charset="utf-8">
<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…">
- <label class="muted" style="margin-left:auto">auto-refresh 60s</label>
+ <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>`
@@ -116,19 +121,58 @@ ${items.map(it => ` <div class="card" data-k="${it.port} ${(it.name||'').toLowe
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 { CACHE = await buildList(); } catch (e) { console.error('scan error', e.message); } building = false; }
-refresh(); setInterval(refresh, 30000);
+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) => {
- if (req.url.startsWith('/api/ports')) {
+ 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}`));
← 552078b Add grid.js: live iframe grid of all local web services
·
back to Port Viewer
·
(newest)