[object Object]

← back to Purelymail Fleet Viewer

viewer: grid view + density slider + collapsible per-domain iframe

76964603c104a2742d0bf093bfad9451d34e71e9 · 2026-05-18 16:41:42 -0700 · SteveStudio2

Files touched

Diff

commit 76964603c104a2742d0bf093bfad9451d34e71e9
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 18 16:41:42 2026 -0700

    viewer: grid view + density slider + collapsible per-domain iframe
---
 public/index.html | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 105 insertions(+), 8 deletions(-)

diff --git a/public/index.html b/public/index.html
index 8fc64b6..bee4134 100644
--- a/public/index.html
+++ b/public/index.html
@@ -47,6 +47,21 @@
   td a{color:var(--gold);text-decoration:none}
   td a:hover{text-decoration:underline}
   .y{color:var(--ok)} .x{color:var(--bad)} .muted{color:var(--dim)}
+  /* collapsible iframe row */
+  tr.drow{cursor:pointer}
+  .caret{display:inline-block;width:11px;color:var(--gold);font-size:10px}
+  tr.detail td{padding:0;background:#0d0c0a}
+  .frame-wrap iframe{width:100%;height:540px;border:0;background:#fff;display:block}
+  .frame-link{padding:6px 10px;font-size:11px}
+  /* grid view */
+  input[type=range]{accent-color:var(--gold);vertical-align:middle}
+  .dgrid{display:grid;grid-template-columns:repeat(var(--cols,3),1fr);gap:14px;margin-top:6px}
+  .dcard{background:var(--panel);border:1px solid var(--line);border-radius:8px;overflow:hidden}
+  .dcard .thumb{width:100%;height:230px;border:0;background:#fff;display:block}
+  .dcard .meta{padding:8px 10px}
+  .dcard .meta .dn{font-size:12px;font-weight:600}
+  .dcard .meta .dn a{color:var(--gold);text-decoration:none}
+  .dcard .meta .st{margin-top:6px;display:flex;gap:4px;flex-wrap:wrap}
   .foot{color:var(--dim);font-size:11px;margin-top:24px;padding-top:12px;border-top:1px solid var(--line)}
   #err{color:var(--bad);font-size:12px}
 </style>
@@ -85,6 +100,15 @@
         <option value="deliverable">Deliverable first</option>
       </select>
     </label>
+    <label>View
+      <select id="view">
+        <option value="table">Table</option>
+        <option value="grid">Grid</option>
+      </select>
+    </label>
+    <label id="densitywrap" style="display:none">Density
+      <input type="range" id="density" min="1" max="6" value="3">
+    </label>
     <span class="sub" id="rowcount"></span>
   </div>
   <table id="domains">
@@ -94,6 +118,7 @@
     </tr></thead>
     <tbody></tbody>
   </table>
+  <div id="grid" class="dgrid" style="display:none"></div>
 
   <div class="foot" id="foot"></div>
 </div>
@@ -101,6 +126,7 @@
 <script>
 const $ = s => document.querySelector(s);
 let DATA = null;
+let lastSig = null;
 
 function pill(cls,txt){ return '<span class="pill '+cls+'">'+txt+'</span>'; }
 function yn(v){ return v ? '<span class="y">✓</span>' : '<span class="x">✗</span>'; }
@@ -148,12 +174,14 @@ function render(d){
   $('#stages').innerHTML = [r.fixA, r.fixB, r.receiveTest, r.sendTest]
     .filter(Boolean).map(stageCard).join('');
 
-  drawTable();
+  // only rebuild the domain table/grid when the domain data actually changed —
+  // avoids reloading every iframe on the 15s auto-refresh
+  const sig = JSON.stringify(d.health.domains || []);
+  if (sig !== lastSig){ lastSig = sig; draw(); }
   $('#foot').textContent = 'purelymail-fleet-viewer · 127.0.0.1:9779 · health source: email-deliverability-agent/output/domain-health.json';
 }
 
-function drawTable(){
-  if (!DATA) return;
+function getRows(){
   let rows = (DATA.health.domains || []).slice();
   const f = $('#filter').value, sortBy = $('#sort').value;
   if (f !== 'all') rows = rows.filter(d => classify(d).k === f);
@@ -162,26 +190,95 @@ function drawTable(){
     if (sortBy==='deliverable') return (b.deliverable?1:0)-(a.deliverable?1:0) || (a.domain||'').localeCompare(b.domain);
     return classify(a).rank - classify(b).rank || (a.domain||'').localeCompare(b.domain);
   });
+  return rows;
+}
+
+function draw(){
+  if (!DATA) return;
+  const v = $('#view').value;
+  $('#domains').style.display = v==='table' ? '' : 'none';
+  $('#grid').style.display = v==='grid' ? '' : 'none';
+  $('#densitywrap').style.display = v==='grid' ? '' : 'none';
+  if (v==='grid') drawGrid(); else drawTable();
+}
+
+function toggleRow(safe, domain){
+  const det = $('#detail-'+safe), caret = $('#caret-'+safe);
+  if (!det) return;
+  const open = det.style.display === 'none';
+  det.style.display = open ? '' : 'none';
+  if (caret) caret.textContent = open ? '▾' : '▸';
+  if (open){
+    const fw = $('#fw-'+safe);
+    if (fw && !fw.dataset.loaded){
+      fw.dataset.loaded = '1';
+      fw.innerHTML = '<iframe src="https://'+domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
+        + '<div class="frame-link"><a href="https://'+domain+'" target="_blank" rel="noopener">open https://'
+        + domain+' ↗ &nbsp;(blank = site blocks embedding)</a></div>';
+    }
+  }
+}
+
+function drawGrid(){
+  const rows = getRows();
+  $('#rowcount').textContent = rows.length + ' shown';
+  $('#grid').innerHTML = rows.map(d=>{
+    const deliv = d.deliverable ? pill('ok','yes') : pill('bad','no');
+    const iss = (d.issues||[]).length ? pill('warn',d.issues.join(', ')) : pill('ok','clean');
+    return '<div class="dcard">'
+      + '<iframe class="thumb" src="https://'+d.domain+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'
+      + '<div class="meta"><div class="dn"><a href="https://'+d.domain
+      + '" target="_blank" rel="noopener">'+d.domain+'</a></div>'
+      + '<div class="st">'+deliv+' '+iss+'</div></div></div>';
+  }).join('') || '<div class="muted">no domains match</div>';
+}
+
+function drawTable(){
+  const rows = getRows();
   $('#rowcount').textContent = rows.length + ' shown';
   $('#domains tbody').innerHTML = rows.map(d=>{
     const deliv = d.deliverable ? pill('ok','yes') : pill('bad','no');
-    const link = '<a href="https://'+d.domain+'" target="_blank" rel="noopener">'+d.domain+'</a>';
-    return '<tr><td>'+link+'</td>'
+    const safe = d.domain.replace(/[^a-z0-9]/gi,'-');
+    const link = '<a href="https://'+d.domain+'" target="_blank" rel="noopener" '
+      + 'onclick="event.stopPropagation()">'+d.domain+'</a>';
+    return '<tr class="drow" onclick="toggleRow(\''+safe+'\',\''+d.domain+'\')">'
+      + '<td><span class="caret" id="caret-'+safe+'">▸</span> '+link+'</td>'
       + '<td>'+yn(d.passesMx)+'</td><td>'+yn(d.passesSpf)+'</td>'
       + '<td>'+yn(d.passesDkim)+'</td><td>'+yn(d.passesDmarc)+'</td>'
       + '<td>'+yn(d.hasRouting)+'</td><td>'+deliv+'</td>'
-      + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td></tr>';
+      + '<td class="muted">'+((d.issues||[]).join(', ')||'—')+'</td></tr>'
+      + '<tr class="detail" id="detail-'+safe+'" style="display:none">'
+      + '<td colspan="8"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
   }).join('') || '<tr><td colspan="8" class="muted">no domains match</td></tr>';
 }
 
+function setDensity(){
+  const n = $('#density').value;
+  $('#grid').style.setProperty('--cols', n);
+  localStorage.setItem('pmfv.density', n);
+}
+
 async function load(){
   try{
     const r = await fetch('/api/status');
     render(await r.json());
   }catch(e){ $('#err').textContent = 'load failed: '+e.message; }
 }
-$('#filter').addEventListener('change', drawTable);
-$('#sort').addEventListener('change', drawTable);
+
+// restore persisted controls (per-namespace localStorage keys)
+['filter','sort','view'].forEach(k=>{
+  const v = localStorage.getItem('pmfv.'+k);
+  if (v!=null && $('#'+k)) $('#'+k).value = v;
+});
+{ const d = localStorage.getItem('pmfv.density'); if (d!=null) $('#density').value = d; }
+
+['filter','sort','view'].forEach(k=>$('#'+k).addEventListener('change',()=>{
+  localStorage.setItem('pmfv.'+k, $('#'+k).value);
+  draw();
+}));
+$('#density').addEventListener('input', setDensity);
+
+setDensity();
 load();
 setInterval(load, 15000);
 </script>

← cdf96c3 receive+send test complete — 202/203 receive, 203/203 send  ·  back to Purelymail Fleet Viewer  ·  viewer: server-rendered screenshots for every domain + Site d0d1ab6 →