[object Object]

← back to Nationalrealestate

Add /health engine status board + /api/ingest-health route

33b1468c90a37128ba6b583b36a6154a658df2fc · 2026-07-26 07:12:29 -0700 · Steve Abrams

Files touched

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 →