TypeScript 55.4%
Python 43.2%
SQL 1.2%
1"use client";23import Link from "next/link";4import { useEffect, useState } from "react";5import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui";6import { fmtInt, relTime, utcDateTime } from "@/lib/format";78/** Shape of GET /api/v1/admin/factory (internal). */9interface FactoryReport {10 seeds: Record<string, number>;11 candidates: Record<string, number>;12 shadow: { total: number; up: number; unhealthy: number; ready: number; raw_changes: number };13 shadow_by_kind: { kind_class: string; n: number }[];14 daily: { day: string; seeds_processed: number; requests: number; candidates: number; shadow_created: number; accepted: number; rejected: number; blocked: number }[];15 sectors: { sector: string; seeds: number; queued: number; discovering: number; discovered: number; blocked: number; errors: number; shadow: number; accepted: number; rejected: number }[];16 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 }[];17 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;18 generated_at: string;19}20interface Candidate {21 id: string;22 source_id: string;23 url: string;24 name: string | null;25 kind_class: string | null;26 connector: string | null;27 tier: string | null;28 status: string;29 score_value: number | null;30 reason: string | null;31 shadow_sensor_id: string | null;32 shadow_health: string | null;33 shadow_runs: number | null;34 shadow_changes: number | null;35 updated_at: string;36}3738export function FactoryPanel({ fetcher, tick }: { fetcher: <T>(path: string, init?: RequestInit) => Promise<T>; tick: number }) {39 const [d, setD] = useState<FactoryReport | null>(null);40 const [err, setErr] = useState<string | null>(null);41 const [cands, setCands] = useState<Candidate[] | null>(null);42 const [candStatus, setCandStatus] = useState("shadow");43 const [busy, setBusy] = useState<string | null>(null);4445 useEffect(() => {46 let cancelled = false;47 fetcher<FactoryReport>("/api/v1/admin/factory")48 .then((r) => !cancelled && (setD(r), setErr(null)))49 .catch((e: Error) => !cancelled && setErr(e.message));50 return () => {51 cancelled = true;52 };53 }, [fetcher, tick]);54 useEffect(() => {55 let cancelled = false;56 fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`)57 .then((r) => !cancelled && setCands(r.items))58 .catch(() => !cancelled && setCands([]));59 return () => {60 cancelled = true;61 };62 }, [fetcher, tick, candStatus]);6364 const act = async (path: string): Promise<void> => {65 setBusy(path);66 try {67 await fetcher(path, { method: "POST", body: "{}" });68 const r = await fetcher<{ items: Candidate[] }>(`/api/v1/admin/factory/candidates?status=${encodeURIComponent(candStatus)}&limit=60`);69 setCands(r.items);70 } catch (e) {71 setErr((e as Error).message);72 } finally {73 setBusy(null);74 }75 };7677 if (err && !d) return <Panel title="Source Factory"><Empty>{err}</Empty></Panel>;78 if (!d) return <Skeleton className="h-40 w-full" />;79 const hb = d.heartbeat;80 const stale = hb?.at ? Date.now() - new Date(hb.at).getTime() > 5 * 60_000 : true;81 const daily = [...d.daily].reverse();82 const seedsTotal = Object.values(d.seeds).reduce((a, b) => a + b, 0);83 return (84 <div className="flex flex-col gap-4">85 <div className="panel grid grid-cols-2 divide-x divide-y divide-line sm:grid-cols-4 xl:grid-cols-8 xl:divide-y-0">86 <Stat label="Factory" value={hb ? (stale ? "STALE" : hb.idle ? "IDLE" : "LIVE") : "NO HEARTBEAT"} tone={hb && !stale ? "signal" : "warn"} hint={hb?.at ? `${relTime(hb.at)} · ${hb.processed ?? 0} processed` : "Redis ws:factory:status empty"} />87 <Stat label="Seeds" value={fmtInt(seedsTotal)} hint={`${fmtInt(d.seeds.queued ?? 0)} queued · ${fmtInt(d.seeds.discovering ?? 0)} discovering`} />88 <Stat label="Discovered" value={fmtInt((d.seeds.discovered ?? 0) + (d.seeds.done ?? 0))} hint={`${fmtInt(d.seeds.blocked ?? 0)} blocked · ${fmtInt(d.seeds.error ?? 0)} errors`} />89 <Stat label="Candidates" value={fmtInt(Object.values(d.candidates).reduce((a, b) => a + b, 0))} hint={`${fmtInt(d.candidates.candidate ?? 0)} waiting · ${fmtInt(d.candidates.duplicate ?? 0)} dup`} />90 <Stat label="Shadow" value={fmtInt(d.shadow.total)} tone="info" hint={`${fmtInt(d.shadow.ready)} ready · ${fmtInt(d.shadow.unhealthy)} unhealthy`} />91 <Stat label="Accepted" value={fmtInt(d.candidates.accepted ?? 0)} tone="signal" hint="production sensors" />92 <Stat label="Rejected" value={fmtInt(d.candidates.rejected ?? 0)} hint="errors · noise · duplicates" />93 <Stat label="Requests today" value={fmtInt(daily.at(-1)?.requests ?? 0)} hint={`${fmtInt(daily.at(-1)?.seeds_processed ?? 0)} organizations`} />94 </div>9596 <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">97 <Panel title="Funnel · 30 days" dense>98 {daily.length ? (99 <div className="grid grid-cols-1 gap-3 p-3 sm:grid-cols-2">100 {(["seeds_processed", "candidates", "shadow_created", "accepted"] as const).map((k) => (101 <div key={k}>102 <div className="mb-1 flex items-baseline justify-between"><span className="label">{k.replace(/_/g, " ")}</span><span className="font-mono text-[12px] tabular">{fmtInt(daily.reduce((a, r) => a + Number(r[k] ?? 0), 0))}</span></div>103 <Sparkline values={daily.map((r) => Number(r[k] ?? 0))} tone={k === "accepted" ? "signal" : k === "shadow_created" ? "info" : "muted"} responsive />104 </div>105 ))}106 </div>107 ) : (108 <Empty>No factory activity yet.</Empty>109 )}110 </Panel>111 <Panel title="By sector" dense className="xl:col-span-2">112 {d.sectors.length ? (113 <div className="max-h-72 overflow-auto">114 <Table head={["Sector", "Seeds", "Queued", "Discovered", "Blocked", "Errors", "Shadow", "Accepted", "Rejected"]}>115 {d.sectors.map((s) => (116 <tr key={s.sector}>117 <Td><Link href={`/coverage/${s.sector}`} className="hover:underline">{s.sector}</Link></Td>118 <Td mono>{fmtInt(s.seeds)}</Td>119 <Td mono className="text-fg-subtle">{fmtInt(s.queued + s.discovering)}</Td>120 <Td mono>{fmtInt(s.discovered)}</Td>121 <Td mono className={s.blocked ? "text-warn" : "text-fg-subtle"}>{fmtInt(s.blocked)}</Td>122 <Td mono className={s.errors ? "text-danger" : "text-fg-subtle"}>{fmtInt(s.errors)}</Td>123 <Td mono className="text-info">{fmtInt(s.shadow)}</Td>124 <Td mono className="text-signal">{fmtInt(s.accepted)}</Td>125 <Td mono className="text-fg-subtle">{fmtInt(s.rejected)}</Td>126 </tr>127 ))}128 </Table>129 </div>130 ) : (131 <Empty>No seeds yet — run <code>cli.ts factory seed</code> or POST /api/v1/admin/factory/seeds.</Empty>132 )}133 </Panel>134 </div>135136 <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">137 <Panel title="Recently processed organizations" dense>138 {d.recent_seeds.length ? (139 <div className="max-h-80 overflow-auto">140 <Table head={["Organization", "Sector", "Status", "Cand.", "Shadow", "Acc.", "Rej.", "When"]}>141 {d.recent_seeds.map((s) => (142 <tr key={s.id}>143 <Td>{s.source_id ? <Link href={`/source/${s.source_id}`} className="hover:underline">{s.name}</Link> : s.name}<div className="font-mono text-[10.5px] text-fg-subtle">{s.domain}{s.last_error ? ` · ${s.last_error}` : ""}</div></Td>144 <Td mono className="text-fg-subtle">{s.sector ?? "—"}</Td>145 <Td><Chip tone={s.status === "discovered" ? "ok" : s.status === "blocked" ? "warn" : s.status === "error" ? "danger" : "default"}>{s.status}</Chip></Td>146 <Td mono>{s.candidates}</Td>147 <Td mono className="text-info">{s.shadow}</Td>148 <Td mono className="text-signal">{s.accepted}</Td>149 <Td mono className="text-fg-subtle">{s.rejected}</Td>150 <Td mono className="text-fg-subtle"><span title={s.discovered_at ? utcDateTime(s.discovered_at) : ""}>{s.discovered_at ? relTime(s.discovered_at) : "—"}</span></Td>151 </tr>152 ))}153 </Table>154 </div>155 ) : (156 <Empty>Nothing processed yet.</Empty>157 )}158 </Panel>159 <Panel160 title="Candidates"161 dense162 action={163 <span className="flex gap-1">164 {["shadow", "candidate", "accepted", "rejected", "duplicate"].map((s) => (165 <button key={s} type="button" onClick={() => setCandStatus(s)} className={`rounded px-1.5 py-0.5 font-mono text-[10.5px] ${candStatus === s ? "bg-panel-2 text-fg" : "text-fg-subtle hover:text-fg"}`}>{s}</button>166 ))}167 </span>168 }169 >170 {cands === null ? (171 <Skeleton className="h-40 w-full" />172 ) : cands.length === 0 ? (173 <Empty>No candidate with status {candStatus}.</Empty>174 ) : (175 <div className="max-h-80 overflow-auto">176 <Table head={["Sensor", "Kind", "Score", "Shadow", "Reason", ""]}>177 {cands.map((c) => (178 <tr key={c.id}>179 <Td><Link href={`/source/${c.source_id}`} className="hover:underline">{c.source_id}</Link><div className="max-w-[28ch] truncate font-mono text-[10.5px] text-fg-subtle" title={c.url}>{c.name ?? ""} · {c.url}</div></Td>180 <Td mono className="text-fg-subtle">{c.kind_class ?? "—"}<div>{c.connector} {c.tier}</div></Td>181 <Td mono>{c.score_value?.toFixed(2) ?? "—"}</Td>182 <Td>{c.shadow_sensor_id ? <span className="flex flex-col gap-0.5"><HealthPill health={c.shadow_health ?? "UP"} /><span className="font-mono text-[10.5px] text-fg-subtle">{c.shadow_runs ?? 0} checks · {c.shadow_changes ?? 0} changes</span></span> : <span className="text-fg-subtle">—</span>}</Td>183 <Td className="text-[11px] text-fg-subtle"><span className="block max-w-[24ch] truncate" title={c.reason ?? ""}>{c.reason ?? "—"}</span></Td>184 <Td>185 {c.status === "shadow" && c.shadow_sensor_id && (186 <span className="flex gap-1">187 <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/shadow/${c.shadow_sensor_id}/accept`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-signal hover:border-line-strong disabled:opacity-50">accept</button>188 <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/shadow/${c.shadow_sensor_id}/reject`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-danger hover:border-line-strong disabled:opacity-50">reject</button>189 </span>190 )}191 {c.status === "candidate" && c.connector && (192 <button type="button" disabled={busy !== null} onClick={() => void act(`/api/v1/admin/factory/candidates/${c.id}/shadow`)} className="rounded border border-line px-1.5 py-0.5 text-[10.5px] text-info hover:border-line-strong disabled:opacity-50">shadow</button>193 )}194 </Td>195 </tr>196 ))}197 </Table>198 </div>199 )}200 </Panel>201 </div>202 {hb?.lastBatch?.length ? <p className="text-[11px] text-fg-subtle">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(" · ")}</p> : null}203 </div>204 );205}206