← back to Purelymail Fleet Viewer
public/index.html
413 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Purelymail Fleet — Live Remediation</title>
<style>
:root{
--bg:#0e0d0b; --panel:#1a1815; --line:#2e2a24; --ink:#ece7dc; --dim:#8c857a;
--gold:#c9a24b; --ok:#3fae6b; --warn:#d6a23a; --bad:#cf5b4e; --run:#5b87c4;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font:14px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
header{padding:22px 28px 14px;border-bottom:1px solid var(--line)}
h1{margin:0;font-size:18px;font-weight:600;letter-spacing:.04em}
h1 span{color:var(--gold)}
.sub{color:var(--dim);font-size:12px;margin-top:4px}
.wrap{padding:20px 28px;max-width:1280px}
h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--gold);
margin:28px 0 10px;font-weight:600}
.cards{display:flex;gap:12px;flex-wrap:wrap;margin:14px 0 4px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:12px 16px;min-width:108px}
.card .n{font-size:24px;font-weight:700}
.card .l{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.05em}
.card.ok .n{color:var(--ok)} .card.warn .n{color:var(--warn)}
.card.bad .n{color:var(--bad)} .card.run .n{color:var(--run)}
.stage{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:8px 0}
.stage h3{margin:0 0 8px;font-size:13px;font-weight:600}
.bar{height:9px;background:#26221b;border-radius:5px;overflow:hidden;margin:8px 0 4px}
.bar i{display:block;height:100%;background:var(--gold);transition:width .4s}
.pill{display:inline-block;padding:1px 8px;border-radius:10px;font-size:11px;font-weight:600}
.pill.ok{background:rgba(63,174,107,.16);color:var(--ok)}
.pill.run{background:rgba(91,135,196,.16);color:var(--run)}
.pill.warn{background:rgba(214,162,58,.16);color:var(--warn)}
.pill.bad{background:rgba(207,91,78,.16);color:var(--bad)}
.pill.pend{background:#26221b;color:var(--dim)}
.ctl{margin:10px 0;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
select{background:var(--panel);color:var(--ink);border:1px solid var(--line);
border-radius:6px;padding:5px 9px;font:inherit;font-size:12px}
table{width:100%;border-collapse:collapse;margin-top:6px}
th,td{text-align:left;padding:5px 9px;border-bottom:1px solid var(--line);font-size:12px}
th{color:var(--dim);font-weight:500;font-size:10px;text-transform:uppercase;letter-spacing:.05em;
cursor:pointer;user-select:none}
tr:hover td{background:#171511}
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}
.shot{width:100%;height:230px;object-fit:cover;object-position:top;
background:#fff;display:block}
.shot.tall{height:540px}
/* cover hero — 1 hi-res vs 4-square */
.hero-bar{display:flex;align-items:center;justify-content:space-between;
gap:12px;margin:18px 0 6px}
.hero-bar h2{margin:0}
#heroToggle{background:var(--gold);color:#1a1710;border:0;border-radius:6px;
padding:7px 14px;font-size:12px;font-weight:600;cursor:pointer}
#heroToggle:hover{filter:brightness(1.08)}
.hero{border:1px solid var(--line);border-radius:10px;overflow:hidden;margin-bottom:8px}
.hero.one img{width:100%;height:460px;object-fit:cover;object-position:top;
background:#fff;display:block}
.hero.quad{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:var(--line)}
.hero.quad img{width:100%;height:225px;object-fit:cover;object-position:top;
background:#fff;display:block}
.hero a{position:relative;display:block;text-decoration:none}
.hero-cap{position:absolute;left:9px;bottom:9px;background:rgba(13,12,10,.8);
color:var(--gold);font-size:11px;padding:3px 9px;border-radius:4px}
.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}
.thumb-wrap{display:block}
.frame-fallback{display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:6px;background:#0d0c0a;color:var(--dim);text-align:center;padding:14px}
.frame-fallback .ff-i{font-size:26px}
.frame-fallback .ff-m{font-size:12px;color:var(--ink)}
.frame-fallback .ff-d{font-size:11px;color:var(--dim)}
.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>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header>
<h1><span>◆</span> Purelymail Fleet — Live Remediation</h1>
<div class="sub" id="stamp">loading…</div>
</header>
<div class="wrap">
<div id="err"></div>
<div class="hero-bar">
<h2>Fleet Cover</h2>
<button id="heroToggle">◱ Show 4-up</button>
</div>
<div id="hero" class="hero quad"></div>
<h2>Fleet Health — 211 domains</h2>
<div class="cards" id="cards"></div>
<div class="sub" id="healthnote"></div>
<h2>Remediation Progress</h2>
<div id="stages"></div>
<h2>Per-Domain Status</h2>
<div class="ctl">
<label>Filter
<select id="filter">
<option value="all">All 211</option>
<option value="dualspf">Dual-SPF (Fix A)</option>
<option value="norouting">No routing (Fix B)</option>
<option value="dead">Dead</option>
<option value="healthy">Fully healthy</option>
<option value="undeliverable">Undeliverable</option>
</select>
</label>
<label>Sort
<select id="sort">
<option value="domain">Domain A→Z</option>
<option value="status">Status (worst first)</option>
<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">
<thead><tr>
<th>Domain</th><th>MX</th><th>SPF</th><th>DKIM</th><th>DMARC</th>
<th>Routing</th><th>Deliverable</th><th>Issues</th><th>Site</th>
</tr></thead>
<tbody></tbody>
</table>
<div id="grid" class="dgrid" style="display:none"></div>
<div class="foot" id="foot"></div>
</div>
<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>'; }
function stageCard(s){
const st = s.status || 'pending';
const cls = st==='done'?'ok':st==='running'?'run':st==='failed'?'bad':'pend';
const pct = s.total ? Math.round((s.done||0)/s.total*100) : (st==='done'?100:0);
let counts = '';
if (s.total != null) counts = (s.done||0)+' / '+s.total;
if (s.passed != null) counts = (s.passed||0)+' passed · '+(s.failed||0)+' failed / '+s.total;
return '<div class="stage"><h3>'+(s.label||'')+' '+pill(cls,st)+'</h3>'
+ '<div class="bar"><i style="width:'+pct+'%"></i></div>'
+ '<div class="sub">'+counts+(s.note?' · '+s.note:'')+'</div></div>';
}
function classify(d){
const i = d.issues || [];
if (i.includes('DEAD')||i.some(x=>/EXPIRED|CANCEL|PARK/i.test(x))) return {k:'dead',rank:0};
if (!d.deliverable) return {k:'undeliverable',rank:1};
if (i.includes('NO_ROUTING')||d.hasRouting===false) return {k:'norouting',rank:2};
if (i.some(x=>/SPF/i.test(x))) return {k:'dualspf',rank:3};
return {k:'healthy',rank:5};
}
function render(d){
DATA = d;
$('#err').textContent = d.healthFound ? '' : 'domain-health.json not found yet — showing remediation status only';
$('#stamp').textContent = 'refreshed ' + new Date(d.generatedAt).toLocaleTimeString()
+ ' · auto-refresh 15s · stage: ' + (d.remediation.stageLabel || d.remediation.stage || '—');
const t = d.health.totals || {};
$('#cards').innerHTML = [
['Total', t.domains ?? '—', ''],
['Healthy', t.fullyHealthy ?? '—', 'ok'],
['Dual-SPF', t.dualSpfRfcViolation ?? '—', 'warn'],
['No routing', t.noRouting ?? '—', 'warn'],
['Dead', t.dead ?? '—', 'bad'],
['Undeliverable', t.undeliverable ?? '—', 'bad'],
].map(c=>'<div class="card '+c[2]+'"><div class="n">'+c[1]+'</div>'
+'<div class="l">'+c[0]+'</div></div>').join('');
$('#healthnote').textContent = d.health.note || '';
const r = d.remediation;
$('#stages').innerHTML = [r.fixA, r.fixB, r.receiveTest, r.sendTest]
.filter(Boolean).map(stageCard).join('');
// 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(); }
if (!heroDrawn){ heroDrawn = true; drawHero(); }
$('#foot').textContent = 'purelymail-fleet-viewer · 127.0.0.1:9779 · health source: email-deliverability-agent/output/domain-health.json';
}
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);
rows.sort((a,b)=>{
if (sortBy==='domain') return (a.domain||'').localeCompare(b.domain||'');
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();
}
const embedResults = {}; // domain -> {reachable, embeddable, status, reason}
let embedRunId = 0;
function shotFail(img, domain, h){
const w = document.createElement('div');
w.className = 'frame-fallback';
w.style.height = (h||230)+'px';
w.innerHTML = '<div class="ff-i">✕</div><div class="ff-m">site not reachable</div>'
+ '<div class="ff-d">'+domain+'</div>';
img.replaceWith(w);
}
// Table detail: live iframe when the site allows embedding, otherwise a
// server-rendered screenshot (always shows — no blank frames).
function frameHTML(domain){
const r = embedResults[domain];
const open = '<div class="frame-link"><a href="https://'+domain
+'" target="_blank" rel="noopener noreferrer">open https://'+domain+' ↗</a></div>';
if (r && r.embeddable){
return '<iframe src="https://'+domain
+'" loading="lazy" referrerpolicy="no-referrer"></iframe>'+open;
}
return '<img class="shot tall" src="/api/shot?domain='+encodeURIComponent(domain)
+'" loading="lazy" onerror="shotFail(this,\''+domain+'\',540)">'+open;
}
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 = frameHTML(domain);
}
}
}
function applyEmbedResult(safe, domain, r){
const cell = $('#site-'+safe);
if (!cell) return;
const cls = r.embeddable ? 'ok' : r.reachable ? 'warn' : 'bad';
const txt = r.embeddable ? 'reachable' : r.reachable ? 'no-embed' : 'unreachable';
cell.innerHTML = '<span class="pill '+cls+'" title="'+(r.reason||'').replace(/"/g,'')+'">'+txt+'</span>';
}
async function runEmbedChecks(domains){
const myRun = ++embedRunId;
const queue = domains.slice();
async function worker(){
while(queue.length){
if (myRun !== embedRunId) return; // a redraw superseded this run
const domain = queue.shift();
const safe = domain.replace(/[^a-z0-9]/gi,'-');
let r = embedResults[domain];
if (!r){
try{
r = await (await fetch('/api/embedcheck?domain='+encodeURIComponent(domain))).json();
}catch(e){ r = {domain, reachable:false, embeddable:false, reason:'check failed'}; }
embedResults[domain] = r;
}
applyEmbedResult(safe, domain, r);
}
}
await Promise.all(Array.from({length:8}, worker)); // 8 concurrent
}
function drawGrid(){
const rows = getRows();
$('#rowcount').textContent = rows.length + ' shown';
$('#grid').innerHTML = rows.map(d=>{
const safe = d.domain.replace(/[^a-z0-9]/gi,'-');
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">'
+ '<img class="shot" src="/api/shot?domain='+encodeURIComponent(d.domain)
+ '" loading="lazy" onerror="shotFail(this,\''+d.domain+'\',230)">'
+ '<div class="meta"><div class="dn"><a href="https://'+d.domain
+ '" target="_blank" rel="noopener noreferrer">'+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 safe = d.domain.replace(/[^a-z0-9]/gi,'-');
const link = '<a href="https://'+d.domain+'" target="_blank" rel="noopener noreferrer" '
+ '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>'
+ '<td id="site-'+safe+'"><span class="pill pend">checking…</span></td></tr>'
+ '<tr class="detail" id="detail-'+safe+'" style="display:none">'
+ '<td colspan="9"><div class="frame-wrap" id="fw-'+safe+'"></div></td></tr>';
}).join('') || '<tr><td colspan="9" class="muted">no domains match</td></tr>';
runEmbedChecks(rows.map(d => d.domain));
}
function setDensity(){
const n = $('#density').value;
$('#grid').style.setProperty('--cols', n);
localStorage.setItem('pmfv.density', n);
}
// --- fleet cover hero ------------------------------------------------------
// One big hi-res screenshot, or a 2x2 of four lower-res ones. The toggle
// re-picks four random fleet domains so each press rotates through the fleet.
let heroMode = localStorage.getItem('pmfv.heroMode') || '4up';
let heroPick = [];
let heroDrawn = false;
function pickHeroDomains(n){
const pool = (DATA && DATA.health.domains || []).map(d=>d.domain);
const out = [];
while (out.length < n && pool.length)
out.push(pool.splice(Math.floor(Math.random()*pool.length),1)[0]);
return out;
}
function heroCard(domain){
return '<a href="https://'+domain+'" target="_blank" rel="noopener noreferrer">'
+ '<img src="/api/shot?domain='+encodeURIComponent(domain)
+ '" loading="lazy" onerror="shotFail(this,\''+domain+'\','+(heroMode==='1up'?460:225)+')">'
+ '<span class="hero-cap">'+domain+'</span></a>';
}
function drawHero(){
if (!DATA) return;
if (heroPick.length < 4) heroPick = pickHeroDomains(4);
const h = $('#hero');
if (heroMode==='1up'){ h.className='hero one'; h.innerHTML = heroCard(heroPick[0]); }
else { h.className='hero quad'; h.innerHTML = heroPick.slice(0,4).map(heroCard).join(''); }
$('#heroToggle').textContent = heroMode==='1up' ? '◱ Show 4-up' : '▢ Show 1 hi-res';
}
function toggleHero(){
heroMode = heroMode==='1up' ? '4up' : '1up';
localStorage.setItem('pmfv.heroMode', heroMode);
heroPick = pickHeroDomains(4); // re-pick so each toggle rotates the fleet
drawHero();
}
async function load(){
try{
const r = await fetch('/api/status');
render(await r.json());
}catch(e){ $('#err').textContent = 'load failed: '+e.message; }
}
// 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);
$('#heroToggle').addEventListener('click', toggleHero);
setDensity();
load();
setInterval(load, 15000);
</script>
</body>
</html>