[object Object]

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