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