"use client"; import Link from "next/link"; import { useEffect, useState } from "react"; import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui"; import { fmtInt, relTime, utcDateTime } from "@/lib/format"; /** Shape of GET /api/v1/admin/factory (internal). */ interface FactoryReport { seeds: Record; candidates: Record; shadow: { total: number; up: number; unhealthy: number; ready: number; raw_changes: number }; shadow_by_kind: { kind_class: string; n: number }[]; daily: { day: string; seeds_processed: number; requests: number; candidates: number; shadow_created: number; accepted: number; rejected: number; blocked: number }[]; sectors: { sector: string; seeds: number; queued: number; discovering: number; discovered: number; blocked: number; errors: number; shadow: number; accepted: number; rejected: number }[]; recent_seeds: { id: string; name: string; domain: string; sector: string | null; status: string; candidates: number; shadow: number; accepted: number; rejected: number; last_error: string | null; discovered_at: string | null; source_id: string | null }[]; heartbeat: { at?: string; processed?: number; queued?: number; concurrency?: number; idle?: boolean; lastBatch?: { seed: string; status: string; candidates: number; shadow: number; requests: number; ms: number }[] } | null; generated_at: string; } interface Candidate { id: string; source_id: string; url: string; name: string | null; kind_class: string | null; connector: string | null; tier: string | null; status: string; score_value: number | null; reason: string | null; shadow_sensor_id: string | null; shadow_health: string | null; shadow_runs: number | null; shadow_changes: number | null; updated_at: string; } export function FactoryPanel({ fetcher, tick }: { fetcher: (path: string, init?: RequestInit) => Promise; tick: number }) { const [d, setD] = useState(null); const [err, setErr] = useState(null); const [cands, setCands] = useState(null); const [candStatus, setCandStatus] = useState("shadow"); const [busy, setBusy] = useState(null); useEffect(() => { let cancelled = false; fetcher("/api/v1/admin/factory") .then((r) => !cancelled && (setD(r), setErr(null))) .catch((e: Error) => !cancelled && setErr(e.message)); return () => { cancelled = true; }; }, [fetcher, tick]); useEffect(() => { let cancelled = false; fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`) .then((r) => !cancelled && setCands(r.items)) .catch(() => !cancelled && setCands([])); return () => { cancelled = true; }; }, [fetcher, tick, candStatus]); const act = async (path: string): Promise => { setBusy(path); try { await fetcher(path, { method: "POST", body: "{}" }); const r = await fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`); setCands(r.items); } catch (e) { setErr((e as Error).message); } finally { setBusy(null); } }; if (err && !d) return {err}; if (!d) return ; const hb = d.heartbeat; const stale = hb?.at ? Date.now() - new Date(hb.at).getTime() > 5 * 60_000 : true; const daily = [...d.daily].reverse(); const seedsTotal = Object.values(d.seeds).reduce((a, b) => a + b, 0); return (
a + b, 0))} hint={`${fmtInt(d.candidates.candidate ?? 0)} waiting · ${fmtInt(d.candidates.duplicate ?? 0)} dup`} />
{daily.length ? (
{(["seeds_processed", "candidates", "shadow_created", "accepted"] as const).map((k) => (
{k.replace(/_/g, " ")}{fmtInt(daily.reduce((a, r) => a + Number(r[k] ?? 0), 0))}
Number(r[k] ?? 0))} tone={k === "accepted" ? "signal" : k === "shadow_created" ? "info" : "muted"} responsive />
))}
) : ( No factory activity yet. )}
{d.sectors.length ? (
{d.sectors.map((s) => ( ))}
{s.sector} {fmtInt(s.seeds)} {fmtInt(s.queued + s.discovering)} {fmtInt(s.discovered)} {fmtInt(s.blocked)} {fmtInt(s.errors)} {fmtInt(s.shadow)} {fmtInt(s.accepted)} {fmtInt(s.rejected)}
) : ( No seeds yet — run cli.ts factory seed or POST /api/v1/admin/factory/seeds. )}
{d.recent_seeds.length ? (
{d.recent_seeds.map((s) => ( ))}
{s.source_id ? {s.name} : s.name}
{s.domain}{s.last_error ? ` · ${s.last_error}` : ""}
{s.sector ?? "—"} {s.status} {s.candidates} {s.shadow} {s.accepted} {s.rejected} {s.discovered_at ? relTime(s.discovered_at) : "—"}
) : ( Nothing processed yet. )}
{["shadow", "candidate", "accepted", "rejected", "duplicate"].map((s) => ( ))} } > {cands === null ? ( ) : cands.length === 0 ? ( No candidate with status {candStatus}. ) : (
{cands.map((c) => ( ))}
{c.source_id}
{c.name ?? ""} · {c.url}
{c.kind_class ?? "—"}
{c.connector} {c.tier}
{c.score_value?.toFixed(2) ?? "—"} {c.shadow_sensor_id ? {c.shadow_runs ?? 0} checks · {c.shadow_changes ?? 0} changes : —} {c.reason ?? "—"} {c.status === "shadow" && c.shadow_sensor_id && ( )} {c.status === "candidate" && c.connector && ( )}
)}
{hb?.lastBatch?.length ?

last batch: {hb.lastBatch.map((b) => `${b.seed} ${b.status} (${b.candidates} cand · ${b.shadow} shadow · ${b.requests} req · ${Math.round(b.ms / 1000)} s)`).join(" · ")}

: null}
); }