SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
12.2 KB · 206 lines tsx
Raw Blame History
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