[object Object]

← back to Homesonspec

admin: live ingestion dashboard (/ingestion) — auto-refreshing throughput from Kamatera DB

b0103f2830fdf042062a837bcbf12bd4ddd71c8d · 2026-07-28 08:14:45 -0700 · Steve Abrams

Files touched

Diff

commit b0103f2830fdf042062a837bcbf12bd4ddd71c8d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 08:14:45 2026 -0700

    admin: live ingestion dashboard (/ingestion) — auto-refreshing throughput from Kamatera DB
---
 apps/admin/src/app/api/ingestion-stats/route.ts | 51 +++++++++++++++
 apps/admin/src/app/ingestion/IngestionLive.tsx  | 86 +++++++++++++++++++++++++
 apps/admin/src/app/ingestion/page.tsx           | 16 +++++
 apps/admin/src/app/layout.tsx                   |  1 +
 4 files changed, 154 insertions(+)

diff --git a/apps/admin/src/app/api/ingestion-stats/route.ts b/apps/admin/src/app/api/ingestion-stats/route.ts
new file mode 100644
index 0000000..ff99f0c
--- /dev/null
+++ b/apps/admin/src/app/api/ingestion-stats/route.ts
@@ -0,0 +1,51 @@
+import { prisma } from "@homesonspec/database";
+import { NextResponse } from "next/server";
+
+export const dynamic = "force-dynamic";
+
+// Live ingestion telemetry for the /ingestion dashboard. All counts cast ::int so the
+// JSON has no BigInt. Read-only; safe to poll frequently.
+export async function GET() {
+  const [byStatus, throughput, runs, staged, topSources] = await Promise.all([
+    prisma.$queryRawUnsafe<{ status: string; n: number }[]>(
+      `SELECT status, count(*)::int AS n FROM "InventoryHome" GROUP BY status ORDER BY n DESC`,
+    ),
+    prisma.$queryRawUnsafe<any[]>(
+      `SELECT
+         count(*)::int AS total,
+         count(*) FILTER (WHERE "updatedAt" > now() - interval '1 min')::int  AS m1,
+         count(*) FILTER (WHERE "updatedAt" > now() - interval '5 min')::int  AS m5,
+         count(*) FILTER (WHERE "updatedAt" > now() - interval '30 min')::int AS m30,
+         count(*) FILTER (WHERE "updatedAt" > now() - interval '60 min')::int AS m60,
+         max("updatedAt") AS newest
+       FROM "InventoryHome"`,
+    ),
+    prisma.$queryRawUnsafe<any[]>(
+      `SELECT
+         count(*) FILTER (WHERE "startedAt" > now() - interval '10 min')::int AS r10,
+         count(*) FILTER (WHERE "startedAt" > now() - interval '60 min')::int AS r60,
+         EXTRACT(EPOCH FROM (now() - max("startedAt")))::int AS newest_age_s
+       FROM "SourceRun"`,
+    ),
+    prisma.$queryRawUnsafe<any[]>(
+      `SELECT count(*)::int AS total,
+         count(*) FILTER (WHERE "updatedAt" > now() - interval '30 min')::int AS s30
+       FROM "StagedRecord"`,
+    ),
+    prisma.$queryRawUnsafe<{ key: string; runs: number; published: number }[]>(
+      `SELECT sr."key" AS key, count(*)::int AS runs, COALESCE(sum(run."published"),0)::int AS published
+       FROM "SourceRun" run JOIN "SourceRegistry" sr ON sr."id" = run."sourceId"
+       WHERE run."startedAt" > now() - interval '60 min'
+       GROUP BY sr."key" ORDER BY runs DESC LIMIT 8`,
+    ).catch(() => [] as { key: string; runs: number; published: number }[]),
+  ]);
+
+  return NextResponse.json({
+    ts: new Date().toISOString(),
+    byStatus,
+    throughput: throughput[0] ?? {},
+    runs: runs[0] ?? {},
+    staged: staged[0] ?? {},
+    topSources,
+  });
+}
diff --git a/apps/admin/src/app/ingestion/IngestionLive.tsx b/apps/admin/src/app/ingestion/IngestionLive.tsx
new file mode 100644
index 0000000..a588b90
--- /dev/null
+++ b/apps/admin/src/app/ingestion/IngestionLive.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+type Stats = {
+  ts: string;
+  byStatus: { status: string; n: number }[];
+  throughput: { total?: number; m1?: number; m5?: number; m30?: number; m60?: number; newest?: string };
+  runs: { r10?: number; r60?: number; newest_age_s?: number };
+  staged: { total?: number; s30?: number };
+  topSources: { key: string; runs: number; published: number }[];
+};
+
+function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {
+  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>
+      <div className="mt-1 text-3xl font-bold tabular-nums">{value}</div>
+      {sub && <div className="mt-0.5 text-xs text-neutral-400">{sub}</div>}
+    </div>
+  );
+}
+
+export default function IngestionLive() {
+  const [s, setS] = useState<Stats | null>(null);
+  const [err, setErr] = useState(false);
+  const [pulse, setPulse] = useState(false);
+
+  useEffect(() => {
+    let alive = true;
+    const load = async () => {
+      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();
+        if (!alive) return;
+        setS(j); setErr(false); setPulse(true); setTimeout(() => alive && setPulse(false), 600);
+      } catch { if (alive) setErr(true); }
+    };
+    load();
+    const id = setInterval(load, 8000);
+    return () => { alive = false; clearInterval(id); };
+  }, []);
+
+  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`;
+
+  return (
+    <div className="mt-4">
+      <div className="flex 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>
+      </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`} />
+      </div>
+
+      <div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+        <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>
+        </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>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/ingestion/page.tsx b/apps/admin/src/app/ingestion/page.tsx
new file mode 100644
index 0000000..280f6e5
--- /dev/null
+++ b/apps/admin/src/app/ingestion/page.tsx
@@ -0,0 +1,16 @@
+import IngestionLive from "./IngestionLive";
+
+export const dynamic = "force-dynamic";
+
+export default function IngestionPage() {
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Live ingestion</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        Real-time throughput of the continuous ingestion loop (pm2 <code>homesonspec-ingest</code> on Kamatera).
+        Numbers refresh automatically.
+      </p>
+      <IngestionLive />
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index 0425406..353cab7 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -16,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
             <Link href="/" className="text-lg font-bold text-neutral-900">
               HomesOnSpec <span className="text-teal-700">Admin</span>
             </Link>
+            <Link href="/ingestion" className="hover:text-teal-700">Live ingestion</Link>
             <Link href="/review" className="hover:text-teal-700">Review queue</Link>
             <Link href="/sources" className="hover:text-teal-700">Sources</Link>
             <Link href="/validation" className="hover:text-teal-700">Validation log</Link>

← db9700f build-loop: source repo .env for DATABASE_URL (fixes PrismaC  ·  back to Homesonspec  ·  feat: LA all-permit ingest + Census permit geocoder + search de505ba →