[object Object]

← back to Homesonspec

admin/ingestion: live sparkline + new-vs-refreshed + error rate + per-source health table

557ae658bb78b3ec2498a8ac2f359ef83d46c918 · 2026-07-28 08:59:24 -0700 · Steve Abrams

Files touched

Diff

commit 557ae658bb78b3ec2498a8ac2f359ef83d46c918
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 08:59:24 2026 -0700

    admin/ingestion: live sparkline + new-vs-refreshed + error rate + per-source health table
---
 apps/admin/src/app/ingestion/IngestionLive.tsx | 96 ++++++++++++++++++++------
 1 file changed, 74 insertions(+), 22 deletions(-)

diff --git a/apps/admin/src/app/ingestion/IngestionLive.tsx b/apps/admin/src/app/ingestion/IngestionLive.tsx
index a588b90..b4922c1 100644
--- a/apps/admin/src/app/ingestion/IngestionLive.tsx
+++ b/apps/admin/src/app/ingestion/IngestionLive.tsx
@@ -1,17 +1,20 @@
 "use client";
 
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
 
+type Src = { key: string; runs: number; published: number; errors: number; last_age_s: number };
 type Stats = {
   ts: string;
   byStatus: { status: string; n: number }[];
-  throughput: { total?: number; m1?: number; m5?: number; m30?: number; m60?: number; newest?: string };
+  throughput: { total?: number; m1?: number; m5?: number; m30?: number; m60?: number };
+  newHomes: { n30?: number | null; n60?: number | null };
   runs: { r10?: number; r60?: number; newest_age_s?: number };
+  health: { err_runs?: number; runs?: number; err_count?: number };
   staged: { total?: number; s30?: number };
-  topSources: { key: string; runs: number; published: number }[];
+  topSources: Src[];
 };
 
-function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
+function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: React.ReactNode }) {
   return (
     <div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm">
       <div className="text-xs uppercase tracking-wide text-neutral-500">{label}</div>
@@ -21,10 +24,30 @@ function Stat({ label, value, sub }: { label: string; value: React.ReactNode; su
   );
 }
 
+// tiny dependency-free SVG sparkline
+function Spark({ data }: { data: number[] }) {
+  const w = 220, h = 40;
+  if (data.length < 2) return <div className="text-xs text-neutral-400">gathering trend…</div>;
+  const max = Math.max(...data, 1);
+  const pts = data.map((v, i) => `${(i / (data.length - 1)) * w},${h - (v / max) * (h - 4) - 2}`).join(" ");
+  const last = data[data.length - 1];
+  return (
+    <svg width={w} height={h} className="overflow-visible">
+      <polyline points={pts} fill="none" stroke="#0d9488" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
+      <circle cx={w} cy={h - (last / max) * (h - 4) - 2} r="3" fill="#0d9488" />
+    </svg>
+  );
+}
+
+const age = (s?: number) =>
+  s == null || s < 0 ? "—" : s < 90 ? `${s}s` : s < 5400 ? `${Math.round(s / 60)}m` : `${(s / 3600).toFixed(1)}h`;
+
 export default function IngestionLive() {
   const [s, setS] = useState<Stats | null>(null);
   const [err, setErr] = useState(false);
   const [pulse, setPulse] = useState(false);
+  const hist = useRef<number[]>([]);
+  const [, tick] = useState(0);
 
   useEffect(() => {
     let alive = true;
@@ -32,9 +55,10 @@ export default function IngestionLive() {
       try {
         const r = await fetch("/api/ingestion-stats", { cache: "no-store" });
         if (!r.ok) throw new Error(String(r.status));
-        const j = await r.json();
+        const j: Stats = await r.json();
         if (!alive) return;
-        setS(j); setErr(false); setPulse(true); setTimeout(() => alive && setPulse(false), 600);
+        hist.current = [...hist.current, j.throughput.m1 ?? 0].slice(-40);
+        setS(j); setErr(false); setPulse(true); setTimeout(() => alive && setPulse(false), 600); tick((n) => n + 1);
       } catch { if (alive) setErr(true); }
     };
     load();
@@ -45,39 +69,67 @@ export default function IngestionLive() {
   if (!s) return <div className="mt-6 text-sm text-neutral-500">{err ? "Stats unavailable." : "Loading live stats…"}</div>;
 
   const ageS = s.runs.newest_age_s ?? 999999;
-  const fresh = ageS < 21600; // <6h = FRESH (matches the freshness canary threshold)
-  const ageStr = ageS < 90 ? `${ageS}s ago` : ageS < 5400 ? `${Math.round(ageS / 60)}m ago` : `${(ageS / 3600).toFixed(1)}h ago`;
+  const fresh = ageS < 21600;
+  const errRuns = s.health.err_runs ?? 0;
+  const totRuns = s.health.runs ?? 0;
+  const errRate = totRuns ? Math.round((errRuns / totRuns) * 100) : 0;
 
   return (
     <div className="mt-4">
-      <div className="flex items-center gap-2 text-sm">
+      <div className="flex flex-wrap items-center gap-2 text-sm">
         <span className={`inline-block h-2.5 w-2.5 rounded-full ${fresh ? "bg-emerald-500" : "bg-red-500"} ${pulse ? "animate-ping" : ""}`} />
         <span className={fresh ? "text-emerald-700" : "text-red-700"}>{fresh ? "LIVE — ingesting" : "STALE — no recent runs"}</span>
-        <span className="text-neutral-400">· newest SourceRun {ageStr} · updated {new Date(s.ts).toLocaleTimeString()}</span>
+        <span className="text-neutral-400">· newest SourceRun {age(ageS)} ago · refreshed {new Date(s.ts).toLocaleTimeString()}</span>
+        <span className={`ml-auto rounded-full px-2 py-0.5 text-xs ${errRate > 25 ? "bg-red-100 text-red-700" : errRate > 0 ? "bg-amber-100 text-amber-800" : "bg-emerald-100 text-emerald-800"}`}>
+          {errRate}% run errors (60m)
+        </span>
       </div>
 
       <div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
         <Stat label="Published homes" value={(s.throughput.total ?? 0).toLocaleString()} sub={s.byStatus.map((b) => `${b.status} ${b.n.toLocaleString()}`).join(" · ")} />
-        <Stat label="Homes refreshed / 5 min" value={(s.throughput.m5 ?? 0).toLocaleString()} sub={`${s.throughput.m1 ?? 0} in the last minute`} />
-        <Stat label="Homes refreshed / 60 min" value={(s.throughput.m60 ?? 0).toLocaleString()} sub={`${s.throughput.m30 ?? 0} in the last 30 min`} />
-        <Stat label="SourceRuns / 10 min" value={(s.runs.r10 ?? 0).toLocaleString()} sub={`${s.runs.r60 ?? 0} in the last hour`} />
+        <Stat
+          label="Homes / min (live)"
+          value={(s.throughput.m1 ?? 0).toLocaleString()}
+          sub={<Spark data={hist.current} />}
+        />
+        <Stat label="Refreshed / 30 min" value={(s.throughput.m30 ?? 0).toLocaleString()} sub={`${(s.throughput.m60 ?? 0).toLocaleString()} in the last hour`} />
+        <Stat
+          label="New homes / 30 min"
+          value={s.newHomes.n30 == null ? "—" : (s.newHomes.n30 ?? 0).toLocaleString()}
+          sub={s.newHomes.n60 == null ? "createdAt n/a" : `${(s.newHomes.n60 ?? 0).toLocaleString()} newly found / hr`}
+        />
       </div>
 
       <div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+        <Stat label="SourceRuns / 10 min" value={(s.runs.r10 ?? 0).toLocaleString()} sub={`${s.runs.r60 ?? 0} in the last hour`} />
         <Stat label="Records staged / 30 min" value={(s.staged.s30 ?? 0).toLocaleString()} sub={`${(s.staged.total ?? 0).toLocaleString()} staged total`} />
       </div>
 
       {s.topSources.length > 0 && (
         <div className="mt-6">
-          <h2 className="text-sm font-semibold text-neutral-600">Most active sources (last 60 min)</h2>
-          <div className="mt-2 flex flex-wrap gap-2">
-            {s.topSources.map((t) => (
-              <span key={t.key} className="rounded-full border border-neutral-200 bg-white px-3 py-1 text-xs">
-                <span className="font-mono">{t.key}</span> <span className="font-semibold tabular-nums">{t.runs}</span> runs
-                <span className="text-neutral-400"> · {t.published.toLocaleString()} pub</span>
-              </span>
-            ))}
-          </div>
+          <h2 className="text-sm font-semibold text-neutral-600">Sources — last 60 min</h2>
+          <table className="mt-2 w-full max-w-2xl text-sm">
+            <thead>
+              <tr className="text-left text-xs uppercase text-neutral-400">
+                <th className="py-1 font-medium">Source</th>
+                <th className="py-1 text-right font-medium">Runs</th>
+                <th className="py-1 text-right font-medium">Published</th>
+                <th className="py-1 text-right font-medium">Errors</th>
+                <th className="py-1 text-right font-medium">Last run</th>
+              </tr>
+            </thead>
+            <tbody>
+              {s.topSources.map((t) => (
+                <tr key={t.key} className="border-t border-neutral-100">
+                  <td className="py-1 font-mono text-xs">{t.key}</td>
+                  <td className="py-1 text-right tabular-nums">{t.runs}</td>
+                  <td className="py-1 text-right tabular-nums">{t.published.toLocaleString()}</td>
+                  <td className={`py-1 text-right tabular-nums ${t.errors > 0 ? "text-red-600 font-semibold" : "text-neutral-400"}`}>{t.errors}</td>
+                  <td className="py-1 text-right tabular-nums text-neutral-500">{age(t.last_age_s)} ago</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
         </div>
       )}
       <p className="mt-6 text-xs text-neutral-400">Auto-refreshes every 8s · served from the Kamatera-local Postgres the ingestion loop writes.</p>

← d44d527 auto-save: 2026-07-28T08:58:52 (1 files) — apps/admin/src/ap  ·  back to Homesonspec  ·  admin/ingestion: guard sparkline last-value (TS strict index c9a3fdf →