← 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>