← back to Nationalrealestate

public/admin.html

111 lines

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%230a0d13'/%3E%3Ctext x='8' y='12' font-size='10' text-anchor='middle' fill='%23c8a24b'%3EUS%3C/text%3E%3C/svg%3E">
<title>USRealEstate — Admin · Ingest Provenance</title>
<style>
  :root{--bg:#0a0d13;--panel:#11151d;--line:#222936;--fg:#e8ecf2;--muted:#8a93a3;--gold:#c8a24b;--red:#f85149;--green:#3fb950;--amber:#d29922;}
  *{box-sizing:border-box;} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;}
  #topbar{position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:18px;padding:10px 18px;
    background:rgba(17,21,29,.94);border-bottom:1px solid var(--line);backdrop-filter:blur(6px);}
  #topbar .brand{font-size:16px;font-weight:700;} #topbar .brand b{color:var(--gold);}
  nav{display:flex;gap:14px;font-size:13px;} nav a{color:var(--muted);text-decoration:none;}
  nav a:hover,nav a.active{color:var(--gold);}
  main{max-width:1200px;margin:0 auto;padding:22px 18px;}
  h2{font-size:15px;color:var(--gold);margin:20px 0 10px;}
  .runbtns{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:6px;}
  .runbtns button{background:var(--panel);border:1px solid var(--line);color:var(--fg);border-radius:8px;
    padding:7px 14px;cursor:pointer;font:13px -apple-system,Segoe UI,Roboto,sans-serif;}
  .runbtns button:hover{border-color:var(--gold);color:var(--gold);}
  .runbtns button:disabled{opacity:.45;cursor:default;}
  #runmsg{font-size:12px;color:var(--muted);margin-bottom:12px;}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:12px;}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;}
  .card .hd{display:flex;align-items:center;gap:8px;}
  .card .src{font-weight:700;} .card .st{margin-left:auto;font-size:11px;font-weight:700;border-radius:8px;padding:2px 9px;}
  .st.ok{background:rgba(63,185,80,.15);color:var(--green);} .st.failed{background:rgba(248,81,73,.15);color:var(--red);}
  .st.running{background:rgba(210,153,34,.15);color:var(--amber);}
  .card .when{font-size:12px;color:var(--muted);margin:5px 0 7px;}
  .card .rows{display:flex;gap:14px;font-size:12px;color:var(--muted);flex-wrap:wrap;}
  .card .rows b{color:var(--fg);font-variant-numeric:tabular-nums;}
  .card .notes{font-size:11px;color:var(--muted);margin-top:7px;word-break:break-word;}
  .card .hash{font-size:10px;color:var(--muted);margin-top:5px;font-family:ui-monospace,Menlo,monospace;word-break:break-all;}
  .empty{color:var(--muted);padding:18px;text-align:center;}
</style>
<link rel="stylesheet" href="/nav-drawer.css">
<link rel="stylesheet" href="/geo-search.css">
</head>
<body>
<div id="topbar">
  <div class="brand"><b>USRealEstate</b> — Admin</div>
  <nav>
    <a href="/">Map</a>
    <a href="/markets.html">Markets</a><a href="/commercial.html">Commercial</a>
    <a href="/compare.html">Compare</a>
    <a href="/brokers.html">Brokers</a>
    <a href="/sources.html">Sources</a>
    <a href="/watchlist.html">Watchlist</a>
    <a href="/admin.html" class="active">Admin</a>
  </nav>
</div>
<main>
  <h2>Run now</h2>
  <div class="runbtns" id="runbtns"></div>
  <div id="runmsg"></div>

  <h2>Ingest runs</h2>
  <div class="grid" id="cards"><div class="empty">loading…</div></div>
</main>
<script>
const JOBS = ['zillow','redfin','fhfa','acs','derive','score','alerts','brokers'];
// standing rule: every admin card shows the record's created date AND time, visible on-card
const fmtWhen = iso => new Date(iso).toLocaleString(undefined,
  { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));

function renderBtns(busy){
  const el = document.getElementById('runbtns');
  el.innerHTML = '';
  for (const j of JOBS){
    const b = document.createElement('button');
    b.textContent = '▶ ' + j;
    b.disabled = !!busy;
    b.onclick = async () => {
      renderBtns(true);
      document.getElementById('runmsg').textContent = 'running ' + j + '…';
      const r = await fetch('/api/admin/run/' + j, { method:'POST' }).then(r=>r.json()).catch(e=>({error:String(e)}));
      document.getElementById('runmsg').textContent = r.error ? (j+' failed: '+r.error) : (j+' finished: '+(r.summary||'ok'));
      renderBtns(false);
      loadRuns();
    };
    el.appendChild(b);
  }
}

async function loadRuns(){
  const d = await fetch('/api/admin/runs').then(r=>r.json());
  const el = document.getElementById('cards');
  if (!d.runs.length){ el.innerHTML = '<div class="empty">no ingest runs yet</div>'; return; }
  el.innerHTML = d.runs.map(r => `
    <div class="card">
      <div class="hd"><span class="src">${r.source}</span><span class="st ${r.status}">${r.status}</span></div>
      <div class="when" title="${r.started_at}">🕓 ${fmtWhen(r.started_at)}${r.finished_at ? ' → '+fmtWhen(r.finished_at) : ''}</div>
      <div class="rows"><span>upserted <b>${(+r.rows_upserted||0).toLocaleString()}</b></span>
        <span>skipped <b>${(+r.rows_skipped||0).toLocaleString()}</b></span>
        <span>run <b>#${r.id}</b></span></div>
      ${r.notes ? `<div class="notes">${esc(r.notes)}</div>` : ''}
      ${r.file_hash ? `<div class="hash">sha256 ${esc(r.file_hash.slice(0,20))}…</div>` : ''}
    </div>`).join('');
}

renderBtns(false);
loadRuns();
setInterval(loadRuns, 30000);
</script>
<script src="/nav-drawer.js"></script>
<script src="/geo-search.js"></script>
</body>
</html>