[object Object]

← back to Port Viewer

Add grid.js: live iframe grid of all local web services

552078bc6cd3a7ab700e685e5cac002e4fa24818 · 2026-06-11 23:01:17 -0700 · Steve Abrams

Files touched

Diff

commit 552078bc6cd3a7ab700e685e5cac002e4fa24818
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 23:01:17 2026 -0700

    Add grid.js: live iframe grid of all local web services
---
 grid.js | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 134 insertions(+)

diff --git a/grid.js b/grid.js
new file mode 100644
index 0000000..fe34890
--- /dev/null
+++ b/grid.js
@@ -0,0 +1,134 @@
+#!/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 = '';
+  try { out = execSync('lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null', { encoding: 'utf8' }); } catch { 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, c[0]);
+  }
+  return [...seen.entries()].map(([port, proc]) => ({ port, proc })).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 }) => {
+    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 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…">
+  <label class="muted" style="margin-left:auto">auto-refresh 60s</label>
+</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>
+      <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)));};
+  setTimeout(()=>location.reload(),60000);
+</script></body></html>`;
+
+// cache: rebuild in the background every 30s; serve cached instantly
+let CACHE = [];
+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);
+
+http.createServer((req, res) => {
+  if (req.url.startsWith('/api/ports')) {
+    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify(CACHE));
+  }
+  res.writeHead(200, { 'Content-Type': 'text/html' });
+  res.end(PAGE(CACHE));
+}).listen(PORT, '127.0.0.1', () => console.log(`[port-grid] http://localhost:${PORT}`));

← a86bf08 broaden .gitignore to cover *.bak.* and *.pre-* snapshot pat  ·  back to Port Viewer  ·  Port grid viewer: iframe-per-service grid + per-port & resta dfc290b →