[object Object]

← back to Homesonspec

admin: live Engine viewer (/engine) — terminal-style tail of the pm2 ingest build-loop

c3c35afeba6b5f6d518d1a0e5c12dd6f6dd593c4 · 2026-07-28 10:39:14 -0700 · Steve Abrams

Files touched

Diff

commit c3c35afeba6b5f6d518d1a0e5c12dd6f6dd593c4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 10:39:14 2026 -0700

    admin: live Engine viewer (/engine) — terminal-style tail of the pm2 ingest build-loop
---
 apps/admin/src/app/api/engine-log/route.ts | 35 ++++++++++++++
 apps/admin/src/app/engine/EngineLive.tsx   | 78 ++++++++++++++++++++++++++++++
 apps/admin/src/app/engine/page.tsx         | 16 ++++++
 apps/admin/src/app/layout.tsx              |  1 +
 4 files changed, 130 insertions(+)

diff --git a/apps/admin/src/app/api/engine-log/route.ts b/apps/admin/src/app/api/engine-log/route.ts
new file mode 100644
index 00000000..e7d2ec20
--- /dev/null
+++ b/apps/admin/src/app/api/engine-log/route.ts
@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server";
+import { open, stat } from "node:fs/promises";
+
+export const dynamic = "force-dynamic";
+
+// Live tail of the ingestion ENGINE (pm2 homesonspec-ingest build-loop). The admin runs on the
+// same Kamatera box as the engine, so it reads the pm2 out-log directly. Bounded tail (last ~56KB)
+// so a huge/rotated log never blows memory. Read-only.
+const LOG = "/root/.pm2/logs/homesonspec-ingest-out.log";
+const TAIL_BYTES = 56 * 1024;
+
+async function tail(path: string): Promise<string> {
+  const st = await stat(path);
+  const start = Math.max(0, st.size - TAIL_BYTES);
+  const len = st.size - start;
+  const fh = await open(path, "r");
+  try {
+    const buf = Buffer.alloc(len);
+    await fh.read(buf, 0, len, start);
+    return buf.toString("utf8");
+  } finally {
+    await fh.close();
+  }
+}
+
+export async function GET() {
+  try {
+    const raw = await tail(LOG);
+    // drop the first (likely partial) line, keep the last ~200
+    const lines = raw.split("\n").slice(1).filter(Boolean).slice(-200);
+    return NextResponse.json({ ts: new Date().toISOString(), ok: true, lines });
+  } catch (e: any) {
+    return NextResponse.json({ ts: new Date().toISOString(), ok: false, error: String(e?.message ?? e), lines: [] });
+  }
+}
diff --git a/apps/admin/src/app/engine/EngineLive.tsx b/apps/admin/src/app/engine/EngineLive.tsx
new file mode 100644
index 00000000..a1865d20
--- /dev/null
+++ b/apps/admin/src/app/engine/EngineLive.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+type Resp = { ts: string; ok: boolean; error?: string; lines: string[] };
+
+function lineClass(l: string): string {
+  if (/SWEEP/i.test(l)) return "text-cyan-300 font-semibold";
+  if (/error|Prisma|throw|ECONN|blocked|disallow|403|429/i.test(l)) return "text-red-400";
+  if (/"published":\s*(?!0\b)\d+/.test(l)) return "text-emerald-300";
+  if (/fanning out|enrich|sleeping/i.test(l)) return "text-amber-300";
+  if (/^\s*(drh|plt|len|dw|kb|tri|tol)\b/i.test(l.replace(/^[0-9T:\-]+:?\s*/, ""))) return "text-neutral-300";
+  return "text-neutral-400";
+}
+
+export default function EngineLive() {
+  const [r, setR] = useState<Resp | null>(null);
+  const [err, setErr] = useState(false);
+  const [live, setLive] = useState(true);
+  const boxRef = useRef<HTMLDivElement>(null);
+  const stick = useRef(true);
+
+  useEffect(() => {
+    let alive = true;
+    const load = async () => {
+      try {
+        const res = await fetch("/api/engine-log", { cache: "no-store" });
+        const j: Resp = await res.json();
+        if (!alive) return;
+        setR(j); setErr(!j.ok);
+      } catch { if (alive) setErr(true); }
+    };
+    load();
+    const id = setInterval(() => live && load(), 2500);
+    return () => { alive = false; clearInterval(id); };
+  }, [live]);
+
+  // auto-scroll to bottom unless the user scrolled up
+  useEffect(() => {
+    const el = boxRef.current;
+    if (el && stick.current) el.scrollTop = el.scrollHeight;
+  }, [r]);
+
+  const onScroll = () => {
+    const el = boxRef.current;
+    if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+  };
+
+  const lines = r?.lines ?? [];
+  const newest = lines[lines.length - 1] ?? "";
+
+  return (
+    <div className="mt-4">
+      <div className="mb-2 flex items-center gap-2 text-sm">
+        <span className={`inline-block h-2.5 w-2.5 rounded-full ${err ? "bg-red-500" : "bg-emerald-500 animate-pulse"}`} />
+        <span className={err ? "text-red-700" : "text-emerald-700"}>{err ? "engine log unavailable" : "engine live"}</span>
+        <span className="text-neutral-400">· {lines.length} lines · updated {r ? new Date(r.ts).toLocaleTimeString() : "…"}</span>
+        <button
+          onClick={() => setLive((v) => !v)}
+          className={`ml-auto rounded-md border px-2 py-0.5 text-xs ${live ? "border-emerald-300 text-emerald-700" : "border-neutral-300 text-neutral-500"}`}
+        >
+          {live ? "⏸ pause" : "▶ resume"}
+        </button>
+      </div>
+      <div
+        ref={boxRef}
+        onScroll={onScroll}
+        className="h-[560px] overflow-auto rounded-xl border border-neutral-800 bg-neutral-950 p-3 font-mono text-xs leading-relaxed shadow-inner"
+      >
+        {err && <div className="text-red-400">{r?.error ?? "could not read the engine log"}</div>}
+        {lines.map((l, i) => (
+          <div key={i} className={`whitespace-pre-wrap break-all ${lineClass(l)}`}>{l}</div>
+        ))}
+      </div>
+      <p className="mt-2 text-xs text-neutral-400 truncate">newest: <span className="font-mono">{newest}</span></p>
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/engine/page.tsx b/apps/admin/src/app/engine/page.tsx
new file mode 100644
index 00000000..ec82c09f
--- /dev/null
+++ b/apps/admin/src/app/engine/page.tsx
@@ -0,0 +1,16 @@
+import EngineLive from "./EngineLive";
+
+export const dynamic = "force-dynamic";
+
+export default function EnginePage() {
+  return (
+    <div>
+      <h1 className="text-2xl font-bold">Ingestion engine</h1>
+      <p className="mt-1 text-sm text-neutral-500">
+        Live tail of the continuous build-loop (pm2 <code>homesonspec-ingest</code> on Kamatera) —
+        watch each adapter × state sweep in real time. Green = homes published, cyan = new sweep, red = errors.
+      </p>
+      <EngineLive />
+    </div>
+  );
+}
diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx
index f1ff8417..7e0d6ce0 100644
--- a/apps/admin/src/app/layout.tsx
+++ b/apps/admin/src/app/layout.tsx
@@ -18,6 +18,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
             </Link>
             <Link href="/business" className="font-semibold text-teal-700 hover:text-teal-900">Business</Link>
             <Link href="/ingestion" className="hover:text-teal-700">Live ingestion</Link>
+            <Link href="/engine" className="hover:text-teal-700">Engine</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>

← a5e1e41e admin/business: fully interactive — every data point hrefs t  ·  back to Homesonspec  ·  engine viewer: empty 'errors: []' no longer flagged red (hon de14cc6d →