← back to Homesonspec

apps/admin/src/app/ingestion/IngestionLive.tsx

148 lines

"use client";

import { useEffect, useRef, useState } from "react";

type Src = { key: string; runs: number; published: number; failed_runs: number; err_records: number; last_age_s: number | null };
// staleness relative to the ~2.5h sweep cadence: >4h (or never) = likely dead, >2.5h = missed a sweep
const RED_S = 14400, AMBER_S = 9000;
const staleClass = (s: number | null) => (s == null || s > RED_S ? "text-red-600 font-semibold" : s > AMBER_S ? "text-amber-600" : "text-neutral-500");
type Stats = {
  ts: string;
  byStatus: { status: string; n: number }[];
  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: Src[];
};

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>
      <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>
  );
}

// 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] ?? 0;
  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;
    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: Stats = await r.json();
        if (!alive) return;
        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();
    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;
  const errRuns = s.health.err_runs ?? 0;
  const errRecords = s.health.err_count ?? 0; // per-RECORD drops (homes that errored mid-run) — the honest signal

  return (
    <div className="mt-4">
      <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 {age(ageS)} ago · refreshed {new Date(s.ts).toLocaleTimeString()}</span>
        <span
          title="Homes that errored out mid-run (per-record parse/validate failures) in the last 60 min. Runs still succeed; these homes are silently dropped."
          className={`ml-auto rounded-full px-2 py-0.5 text-xs ${errRecords > 0 ? "bg-amber-100 text-amber-800" : "bg-emerald-100 text-emerald-800"}`}
        >
          {errRuns > 0 && `${errRuns} failed runs · `}{errRecords.toLocaleString()} homes dropped (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 / 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">
            Sources — all active <span className="font-normal text-neutral-400">({s.topSources.filter((t) => t.last_age_s == null || t.last_age_s > RED_S).length} stale/absent · stats over last 60 min)</span>
          </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" title="Homes dropped to per-record errors (runs still succeed)">Dropped</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.err_records > 0 ? "text-amber-600 font-semibold" : "text-neutral-400"}`} title={t.failed_runs > 0 ? `${t.failed_runs} failed runs` : ""}>{t.err_records.toLocaleString()}{t.failed_runs > 0 ? " ⚠" : ""}</td>
                  <td className={`py-1 text-right tabular-nums ${staleClass(t.last_age_s)}`}>
                    {t.last_age_s == null ? "never run" : `${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>
    </div>
  );
}