← 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
A apps/admin/src/app/api/ingestion-stats/route.tsA apps/admin/src/app/ingestion/IngestionLive.tsxA apps/admin/src/app/ingestion/page.tsxM apps/admin/src/app/layout.tsx
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 →