← back to Nationalrealestate
Add /health engine status board + /api/ingest-health route
33b1468c90a37128ba6b583b36a6154a658df2fc · 2026-07-26 07:12:29 -0700 · Steve Abrams
Files touched
A public/health.htmlM src/server/index.ts
Diff
commit 33b1468c90a37128ba6b583b36a6154a658df2fc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jul 26 07:12:29 2026 -0700
Add /health engine status board + /api/ingest-health route
---
public/health.html | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++
src/server/index.ts | 31 ++++++++++++++++
2 files changed, 132 insertions(+)
diff --git a/public/health.html b/public/health.html
new file mode 100644
index 0000000..f11fc92
--- /dev/null
+++ b/public/health.html
@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>USRealEstate — Ingest Engine Health</title>
+<style>
+ :root{--bg:#0f1216;--card:#181c22;--line:#262c34;--muted:#8b95a3;--fg:#e7ecf2;
+ --healthy:#2ecc71;--degraded:#f1c40f;--retrying:#e67e22;--quarantined:#e74c3c;--unknown:#6b7684;}
+ *{box-sizing:border-box}
+ body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
+ header{padding:18px 22px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
+ h1{font-size:18px;margin:0;font-weight:650;letter-spacing:.2px}
+ .sub{color:var(--muted);font-size:12px}
+ .wrap{padding:22px;max-width:1100px;margin:0 auto}
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
+ .card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
+ .card h3{margin:0 0 8px;font-size:15px;display:flex;align-items:center;gap:8px}
+ .dot{width:10px;height:10px;border-radius:50%;display:inline-block}
+ .badge{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:2px 8px;border-radius:99px;margin-left:auto}
+ .kv{display:flex;justify-content:space-between;color:var(--muted);font-size:12.5px;padding:2px 0}
+ .kv b{color:var(--fg);font-weight:550}
+ .reason{margin-top:8px;font-size:12px;color:var(--muted);border-top:1px dashed var(--line);padding-top:8px}
+ section h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:26px 0 10px}
+ .quota-bar{height:10px;background:#222831;border-radius:6px;overflow:hidden;margin-top:6px}
+ .quota-fill{height:100%;background:linear-gradient(90deg,#2ecc71,#f1c40f)}
+ table{width:100%;border-collapse:collapse;font-size:12.5px}
+ td,th{text-align:left;padding:6px 10px;border-bottom:1px solid var(--line)}
+ th{color:var(--muted);font-weight:550}
+ .ev{font-weight:600}
+ .muted{color:var(--muted)}
+ a{color:#5aa9ff;text-decoration:none}
+</style>
+</head>
+<body>
+<header>
+ <h1>⚙︎ Ingest Engine Health</h1>
+ <span class="sub" id="stamp">loading…</span>
+ <span class="sub" style="margin-left:auto"><a href="/">← portal</a> · auto-refresh 30s</span>
+</header>
+<div class="wrap">
+ <div class="grid" id="cards"></div>
+
+ <section>
+ <h2>Google Places — monthly quota (free-tier hard cap)</h2>
+ <div class="card" id="quota">…</div>
+ </section>
+
+ <section>
+ <h2>Recent self-correction events</h2>
+ <div class="card" style="padding:4px 0"><table id="events"><tbody><tr><td class="muted" style="padding:12px">…</td></tr></tbody></table></div>
+ </section>
+</div>
+
+<script>
+const COLORS = {healthy:'var(--healthy)',degraded:'var(--degraded)',retrying:'var(--retrying)',quarantined:'var(--quarantined)',unknown:'var(--unknown)'};
+const EVCOLOR = {recovered:'var(--healthy)',degraded:'var(--degraded)',retry:'var(--retrying)',probe:'#5aa9ff',quarantined:'var(--quarantined)'};
+
+function num(v){ return v==null?'—':Number(v).toLocaleString(); }
+
+async function load(){
+ let d;
+ try { d = await (await fetch('/api/ingest-health',{headers:{'accept':'application/json'}})).json(); }
+ catch(e){ document.getElementById('stamp').textContent='fetch failed'; return; }
+ if(!d.ok){ document.getElementById('stamp').textContent='error: '+d.error; return; }
+
+ document.getElementById('stamp').textContent =
+ d.sources.length+' streams · updated '+new Date(d.generated_at).toLocaleTimeString();
+
+ document.getElementById('cards').innerHTML = d.sources.map(s=>{
+ const c = COLORS[s.state]||COLORS.unknown;
+ const base = s.baseline_upserted ? ' / base '+num(s.baseline_upserted) : '';
+ return `<div class="card">
+ <h3><span class="dot" style="background:${c}"></span>${s.job}
+ <span class="badge" style="background:${c}22;color:${c}">${s.state}</span></h3>
+ <div class="kv"><span>last OK</span><b>${s.last_ok_at||'never'}${s.ok_age_h!=null?` · ${s.ok_age_h}h ago`:''}</b></div>
+ <div class="kv"><span>last rows</span><b>${num(s.last_upserted)}${base}</b></div>
+ <div class="kv"><span>consec. failures</span><b>${s.consecutive_failures||0}</b></div>
+ ${s.quarantined_at?`<div class="kv"><span>quarantined</span><b style="color:var(--quarantined)">${s.quarantined_at}</b></div>`:''}
+ </div>`;
+ }).join('') || '<div class="card muted">No source health yet — the loop hasn\'t run a tick.</div>';
+
+ const q = d.places_quota, pct = q.cap? Math.min(100,Math.round(100*q.calls_used/q.cap)):0;
+ document.getElementById('quota').innerHTML =
+ `<div class="kv"><span>${q.year_month||'this month'}</span><b>${num(q.calls_used)} / ${num(q.cap)} calls · $0 (free tier)</b></div>
+ <div class="quota-bar"><div class="quota-fill" style="width:${pct}%"></div></div>
+ <div class="reason">Calls stop automatically at the cap — no spend is possible.</div>`;
+
+ document.getElementById('events').innerHTML =
+ '<thead><tr><th>when</th><th>stream</th><th>event</th><th>detail</th></tr></thead><tbody>' +
+ (d.events.length ? d.events.map(e=>
+ `<tr><td class="muted">${e.at}</td><td>${e.job}</td>
+ <td class="ev" style="color:${EVCOLOR[e.event]||'var(--fg)'}">${e.event}</td>
+ <td class="muted">${e.detail||''}</td></tr>`).join('')
+ : '<tr><td class="muted" style="padding:12px" colspan="4">No events — every source healthy since the engine started.</td></tr>') +
+ '</tbody>';
+}
+load(); setInterval(load, 30000);
+</script>
+</body>
+</html>
diff --git a/src/server/index.ts b/src/server/index.ts
index 7e62b0e..305f70b 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -54,6 +54,37 @@ app.get('/api/health', async (_req, res) => {
}
});
+// Self-correcting engine status — per-source health + recent events + Places quota.
+app.get('/api/ingest-health', async (_req, res) => {
+ try {
+ const sources = await query(
+ `SELECT sh.job, sh.state, sh.consecutive_failures, sh.baseline_upserted, sh.last_upserted,
+ to_char(sh.last_ok_at,'YYYY-MM-DD HH24:MI') AS last_ok_at,
+ to_char(sh.last_failure_at,'YYYY-MM-DD HH24:MI') AS last_failure_at,
+ to_char(sh.quarantined_at,'YYYY-MM-DD HH24:MI') AS quarantined_at,
+ ROUND(EXTRACT(EPOCH FROM (NOW()-sh.last_ok_at))/3600, 1) AS ok_age_h
+ FROM source_health sh ORDER BY
+ CASE sh.state WHEN 'quarantined' THEN 0 WHEN 'degraded' THEN 1
+ WHEN 'retrying' THEN 2 WHEN 'healthy' THEN 3 ELSE 4 END, sh.job`);
+ const events = await query(
+ `SELECT job, event, detail, to_char(at,'MM-DD HH24:MI') AS at
+ FROM source_events ORDER BY id DESC LIMIT 20`);
+ const pq = await query<{ year_month: string; calls_used: number }>(
+ `SELECT year_month, calls_used FROM places_quota ORDER BY year_month DESC LIMIT 1`);
+ res.json({
+ ok: true,
+ generated_at: new Date().toISOString(),
+ sources: sources.rows,
+ events: events.rows,
+ places_quota: { ...(pq.rows[0] || { year_month: null, calls_used: 0 }), cap: Number(process.env.PLACES_MONTHLY_CAP || 1500) },
+ });
+ } catch (e: any) {
+ res.status(500).json({ ok: false, error: String(e.message || e) });
+ }
+});
+
+app.get('/health', (_req, res) => res.sendFile(join(ROOT, 'public', 'health.html')));
+
app.get('/api/regions', async (req, res) => {
try {
const type = String(req.query.type || 'county');
← 7ced2c2 Self-correcting engine: health-aware loop (backoff, quaranti
·
back to Nationalrealestate
·
Per-state broker health + West-Coast-first geographic Places aa800ba →