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%
37.9 KB · 712 lines tsx
Raw Blame History
1"use client";23import { KeyRound, LogOut, Play, RefreshCw, ShieldAlert } from "lucide-react";4import Link from "next/link";5import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";6import { SENSOR_TYPES, TIERS } from "@websensor/core/client";7import { useMounted } from "@/components/theme";8import { Chip, Empty, HealthPill, Panel, Skeleton, Sparkline, Stat, Table, Td } from "@/components/ui";9import { clientApiBase } from "@/lib/api";10import { FactoryPanel } from "./factory-panel";11import { fmtBytes, fmtInt, fmtMs, fmtPct, relTime, untilTime, utcDateTime, utcTime } from "@/lib/format";1213const KEY = "ws_admin";1415// ---------------------------------------------------------------------------------------16// Types for GET /api/v1/admin/ops (kept local: internal shape, not part of the public client)17// ---------------------------------------------------------------------------------------1819interface Ops {20  db: { latency_ms: number };21  queue: { due: number; overdue_10m: number; p0: number; p0_due: number; oldest_due: string | null };22  engine: { inflight?: number; concurrency?: number; due?: number; busyHosts?: { host: string; inflight: number }[]; circuitOpen?: { host: string; failures: number; until: string }[]; at?: string; version?: string; [k: string]: unknown } | null;23  outcomes_1h: { outcome: string; n: number }[];24  top_failing_domains: { host: string; failures: number; sensors: number; last_error: string | null; last_at: string }[];25  slowest_sensors: { id: string; name: string; source_id: string; url: string; connector: string; avg_latency_ms: number; total_runs: number }[];26  throughput_24h: { t: string; checks: number; not_modified: number; errors: number; events: number; avg_ms: number | null }[];27  storage: { db_size: string; db_bytes: string | number; snapshots: number; snapshots_with_raw: number; raw_bytes_uncompressed: string | number; changes: number; events: number; runs: number; blobs: { files: number; bytes: number } | null };28  llm_24h: { model: string; calls: number; input_tokens: string | number; output_tokens: string | number; failures: number }[];29  sensors_by_status: { status: string; health: string; n: number }[];30  recent_errors: { started_at: string; sensor_id: string; source_id: string; connector: string; http_status: number | null; outcome: string; error: string | null }[];31  cache: { entries: number; keys: string[] };32  live: { clients: number; published: number };33  connectors: { key: string; name: string; version?: string; sensorTypes?: string[]; description?: string }[];34  generated_at: string;35}3637class AdminError extends Error {38  status: number;39  body: Record<string, unknown>;40  constructor(status: number, body: Record<string, unknown>) {41    super(typeof body.error === "string" ? body.error : `HTTP ${status}`);42    this.status = status;43    this.body = body;44  }45}4647function readToken(): string {48  if (typeof window === "undefined") return "";49  return window.sessionStorage.getItem(KEY) ?? "";50}5152async function adminFetch<T>(path: string, init: RequestInit = {}, token = readToken()): Promise<T> {53  const headers: Record<string, string> = { accept: "application/json", "x-websensor-admin": token, ...((init.headers as Record<string, string>) ?? {}) };54  if (init.body !== undefined && !headers["content-type"]) headers["content-type"] = "application/json";55  const res = await fetch(`${clientApiBase()}${path}`, { ...init, headers });56  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;57  if (!res.ok) throw new AdminError(res.status, body);58  return body as T;59}6061/** A heartbeat older than two minutes means the scheduler stopped publishing. */62function heartbeatStale(at: string | undefined): boolean {63  return Boolean(at) && Date.now() - new Date(at as string).getTime() > 120_000;64}6566function gateMessage(e: unknown): string {67  if (e instanceof AdminError) {68    if (e.status === 401) return "invalid token";69    if (e.status === 404) return "admin API disabled (WS_ADMIN_TOKEN not set)";70    return `${e.message}${typeof e.body.detail === "string" ? ` — ${e.body.detail}` : ""}`;71  }72  return (e as Error).message;73}7475// ---------------------------------------------------------------------------------------7677export function Ops() {78  const mounted = useMounted();79  /** null = use the token stored in sessionStorage; "" = signed out; otherwise the token entered in this session. */80  const [override, setOverride] = useState<string | null>(null);81  const token: string | null = !mounted ? null : override ?? readToken();82  const [gate, setGate] = useState<string | null>(null);83  const [data, setData] = useState<Ops | null>(null);84  const [err, setErr] = useState<string | null>(null);85  const [refreshing, setRefreshing] = useState(false);86  const [input, setInput] = useState("");87  const [tick, setTick] = useState(0);8889  // Poll /admin/ops every 15 s while a token is present (and on manual refresh via `tick`).90  useEffect(() => {91    if (!token) return;92    const t = token;93    let cancelled = false;94    const pull = (): void => {95      adminFetch<Ops>("/api/v1/admin/ops", {}, t)96        .then((d) => {97          if (cancelled) return;98          setData(d);99          setErr(null);100          setGate(null);101        })102        .catch((e: unknown) => {103          if (cancelled) return;104          const msg = gateMessage(e);105          if (e instanceof AdminError && (e.status === 401 || e.status === 404)) {106            setGate(msg);107            setData(null);108          } else setErr(msg);109        })110        .finally(() => {111          if (!cancelled) setRefreshing(false);112        });113    };114    pull();115    const iv = setInterval(pull, 15_000);116    return () => {117      cancelled = true;118      clearInterval(iv);119    };120  }, [token, tick]);121122  const refresh = (): void => {123    setRefreshing(true);124    setTick((x) => x + 1);125  };126  const factoryFetcher = useCallback(<T,>(path: string, init: RequestInit = {}): Promise<T> => adminFetch<T>(path, init, token ?? ""), [token]);127  const submitToken = async (): Promise<void> => {128    const t = input.trim();129    if (!t) return;130    try {131      const d = await adminFetch<Ops>("/api/v1/admin/ops", {}, t);132      window.sessionStorage.setItem(KEY, t);133      setData(d);134      setGate(null);135      setErr(null);136      setOverride(t);137      setInput("");138    } catch (e) {139      window.sessionStorage.removeItem(KEY);140      setGate(gateMessage(e));141    }142  };143  const signOut = (): void => {144    window.sessionStorage.removeItem(KEY);145    setOverride("");146    setData(null);147    setGate(null);148  };149150  if (token === null) return <Skeleton className="h-24 w-full" />;151  if (!token || (gate && !data)) {152    return (153      <Panel>154        <form155          className="mx-auto flex max-w-md flex-col gap-2 py-6 text-[13px]"156          onSubmit={(e) => {157            e.preventDefault();158            void submitToken();159          }}160        >161          <p className="leading-relaxed text-fg-muted">162            <KeyRound className="mr-1.5 inline size-4 align-text-bottom" />163            Enter the admin token (<code>WS_ADMIN_TOKEN</code>). It is kept in <code>sessionStorage</code> for this tab only and sent as <code>X-WebSensor-Admin</code>.164          </p>165          <div className="flex gap-1">166            <input type="password" value={input} onChange={(e) => setInput(e.target.value)} placeholder="admin token" autoComplete="off" aria-label="Admin token" className="h-8 min-w-0 flex-1 rounded-md border border-line bg-panel px-2 font-mono text-[12.5px]" />167            <button type="submit" className="inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-3 text-[12.5px] hover:border-line-strong">Unlock</button>168          </div>169          {gate && <p className="flex items-center gap-1 text-[12px] text-danger"><ShieldAlert className="size-3.5" /> {gate}</p>}170          <p className="text-[11px] text-fg-subtle">Public health is at <Link href="/health" className="text-info hover:underline">/health</Link>.</p>171        </form>172      </Panel>173    );174  }175176  return (177    <div className="flex flex-col gap-4">178      <div className="flex flex-wrap items-center justify-between gap-2 text-[11.5px] text-fg-subtle">179        <span className="flex items-center gap-2">180          <span className={`inline-block size-2 rounded-full ${err ? "bg-danger" : "bg-signal animate-pulse-dot"}`} />181          {data ? `generated ${utcTime(data.generated_at)} UTC · db ${data.db.latency_ms} ms · ${data.live.clients} WS clients` : "loading…"}182          {err && <span className="text-danger">· {err}</span>}183        </span>184        <span className="flex items-center gap-2">185          <button type="button" onClick={refresh} disabled={refreshing} className="inline-flex h-7 items-center gap-1 rounded-md border border-line bg-panel px-2 hover:border-line-strong disabled:opacity-60"><RefreshCw className={`size-3 ${refreshing ? "animate-spin" : ""}`} /> refresh</button>186          <button type="button" onClick={signOut} className="inline-flex h-7 items-center gap-1 rounded-md border border-line bg-panel px-2 hover:border-line-strong"><LogOut className="size-3" /> forget token</button>187        </span>188      </div>189190      {data ? <Dashboard d={data} /> : <Skeleton className="h-64 w-full" />}191192      <h2 className="mt-2 text-[13px] font-semibold tracking-tight">Source Factory <span className="font-normal text-fg-subtle">· discovery → shadow → acceptance</span></h2>193      <FactoryPanel fetcher={factoryFetcher} tick={tick} />194195      <Tools connectors={data?.connectors ?? []} onDone={refresh} />196    </div>197  );198}199200// ---------------------------------------------------------------------------------------201// Dashboard panels202// ---------------------------------------------------------------------------------------203204function Dashboard({ d }: { d: Ops }) {205  const eng = d.engine;206  const stale = heartbeatStale(eng?.at);207  const tp = d.throughput_24h;208  const sum = (k: "checks" | "events" | "errors" | "not_modified"): number => tp.reduce((a, r) => a + Number(r[k] ?? 0), 0);209  const outcomes = d.outcomes_1h;210  const oTotal = outcomes.reduce((a, o) => a + o.n, 0);211  const byStatus = useMemo(() => {212    const m = new Map<string, { n: number; health: Record<string, number> }>();213    for (const r of d.sensors_by_status) {214      const cur = m.get(r.status) ?? { n: 0, health: {} };215      cur.n += r.n;216      cur.health[r.health] = (cur.health[r.health] ?? 0) + r.n;217      m.set(r.status, cur);218    }219    return [...m.entries()].sort((a, b) => b[1].n - a[1].n);220  }, [d.sensors_by_status]);221  const raw = Number(d.storage.raw_bytes_uncompressed);222  const blobBytes = d.storage.blobs?.bytes ?? 0;223224  return (225    <>226      <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">227        <Stat label="Engine" value={eng ? (stale ? "STALE" : "LIVE") : "NO HEARTBEAT"} tone={eng && !stale ? "signal" : "warn"} hint={eng?.at ? `${relTime(eng.at)} · ${eng.version ?? "?"}` : "Redis ws:engine:status empty"} />228        <Stat label="In flight" value={eng ? `${eng.inflight ?? 0} / ${eng.concurrency ?? "—"}` : "—"} hint="workers / concurrency" />229        <Stat label="Queue due" value={fmtInt(d.queue.due)} tone={d.queue.overdue_10m > 0 ? "warn" : undefined} hint={`${fmtInt(d.queue.overdue_10m)} overdue > 10 min`} />230        <Stat label="P0 · due" value={`${fmtInt(d.queue.p0)} · ${fmtInt(d.queue.p0_due)}`} tone={d.queue.p0_due > 0 ? "hot" : undefined} hint={d.queue.oldest_due ? `oldest ${untilTime(d.queue.oldest_due)}` : "—"} />231        <Stat label="Checks 24 h" value={fmtInt(sum("checks"))} hint={`${fmtPct(sum("checks") ? sum("not_modified") / sum("checks") : null)} 304`} />232        <Stat label="Events 24 h" value={fmtInt(sum("events"))} tone="signal" hint={`${fmtInt(sum("errors"))} errors`} />233        <Stat label="Circuit open" value={fmtInt(eng?.circuitOpen?.length ?? 0)} tone={(eng?.circuitOpen?.length ?? 0) > 0 ? "warn" : undefined} hint={`${eng?.busyHosts?.length ?? 0} busy hosts`} />234        <Stat label="Cache" value={fmtInt(d.cache.entries)} hint="TTL entries" />235      </div>236237      <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">238        <Panel title="Engine heartbeat">239          {eng ? (240            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">241              <Row k="At" v={utcDateTime(eng.at)} />242              <Row k="Version" v={eng.version ?? "—"} />243              <Row k="In flight" v={`${eng.inflight ?? 0} / ${eng.concurrency ?? "—"}`} />244              <Row k="Due (engine view)" v={fmtInt(eng.due)} />245              <Row k="Busy hosts" v={eng.busyHosts?.length ? eng.busyHosts.slice(0, 8).map((h) => `${h.host} ×${h.inflight}`).join(", ") : "none"} />246              <Row k="Circuit open" v={eng.circuitOpen?.length ? eng.circuitOpen.slice(0, 8).map((h) => `${h.host} (${h.failures}, until ${utcTime(h.until, false)})`).join(", ") : "none"} tone={eng.circuitOpen?.length ? "text-warn" : ""} />247            </dl>248          ) : (249            <Empty>No heartbeat in Redis — the engine is not running or cannot reach Redis.</Empty>250          )}251        </Panel>252        <Panel title="Outcomes · 1 h">253          {outcomes.length ? (254            <ul className="flex flex-col gap-1.5 text-[12.5px]">255              {outcomes.map((o) => (256                <li key={o.outcome} className="grid grid-cols-[7rem_1fr_3.5rem] items-center gap-2">257                  <span className="truncate font-mono text-[11.5px]">{o.outcome}</span>258                  <div className="h-1.5 overflow-hidden rounded-full bg-panel-2"><div className={`h-full ${o.outcome === "error" || o.outcome === "parse_error" || o.outcome === "rate_limited" ? "bg-danger" : o.outcome === "event" ? "bg-signal" : o.outcome === "not_modified" ? "bg-info" : "bg-low"}`} style={{ width: `${oTotal ? (o.n / oTotal) * 100 : 0}%` }} /></div>259                  <span className="text-right font-mono tabular">{fmtInt(o.n)}</span>260                </li>261              ))}262            </ul>263          ) : (264            <Empty>No runs in the last hour.</Empty>265          )}266        </Panel>267        <Panel title="Storage">268          <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[12.5px]">269            <Row k="Database" v={d.storage.db_size} />270            <Row k="Snapshots" v={`${fmtInt(d.storage.snapshots)} · ${fmtInt(d.storage.snapshots_with_raw)} with raw`} />271            <Row k="Raw (uncompressed)" v={fmtBytes(raw)} />272            <Row k="Blob store" v={d.storage.blobs ? `${fmtInt(d.storage.blobs.files)} files · ${fmtBytes(blobBytes)}` : "—"} />273            <Row k="Compression" v={raw && blobBytes ? `${(raw / blobBytes).toFixed(1)}×` : "—"} />274            <Row k="Changes / events" v={`${fmtInt(d.storage.changes)} / ${fmtInt(d.storage.events)}`} />275            <Row k="Runs" v={fmtInt(d.storage.runs)} />276          </dl>277        </Panel>278      </div>279280      <Panel title="Throughput · 24 h · hourly" dense>281        {tp.length ? (282          <div className="grid grid-cols-1 gap-3 p-3 sm:grid-cols-3">283            <Spark label="Checks" values={tp.map((r) => r.checks)} tone="info" />284            <Spark label="Events" values={tp.map((r) => r.events)} tone="signal" />285            <Spark label="Errors" values={tp.map((r) => r.errors)} tone="hot" />286          </div>287        ) : (288          <Empty>No sensor runs recorded in the last 24 h.</Empty>289        )}290        {tp.length > 0 && (291          <div className="max-h-56 overflow-auto border-t border-line">292            <Table head={["Hour (UTC)", "Checks", "304", "Errors", "Events", "Avg ms"]}>293              {[...tp].reverse().map((r) => (294                <tr key={r.t}>295                  <Td mono>{r.t.slice(5, 16).replace("T", " ")}</Td>296                  <Td mono>{fmtInt(r.checks)}</Td>297                  <Td mono className="text-fg-subtle">{fmtInt(r.not_modified)}</Td>298                  <Td mono className={r.errors > 0 ? "text-danger" : ""}>{fmtInt(r.errors)}</Td>299                  <Td mono className="text-signal">{fmtInt(r.events)}</Td>300                  <Td mono>{fmtMs(r.avg_ms)}</Td>301                </tr>302              ))}303            </Table>304          </div>305        )}306      </Panel>307308      <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">309        <Panel title="Sensors by status / health" dense>310          {byStatus.length ? (311            <Table head={["Status", "Sensors", "Health"]}>312              {byStatus.map(([status, v]) => (313                <tr key={status}>314                  <Td mono>{status}</Td>315                  <Td mono>{fmtInt(v.n)}</Td>316                  <Td>317                    <div className="flex flex-wrap gap-1">318                      {Object.entries(v.health).sort((a, b) => b[1] - a[1]).map(([h, n]) => (319                        <span key={h} className="inline-flex items-center gap-1"><HealthPill health={h} /><span className="font-mono text-[11px] text-fg-subtle tabular">{fmtInt(n)}</span></span>320                      ))}321                    </div>322                  </Td>323                </tr>324              ))}325            </Table>326          ) : (327            <Empty />328          )}329        </Panel>330        <Panel title="LLM · 24 h" dense>331          {d.llm_24h.length ? (332            <Table head={["Model", "Calls", "In", "Out", "Fail"]}>333              {d.llm_24h.map((m) => (334                <tr key={m.model}>335                  <Td mono className="whitespace-nowrap">{m.model}</Td>336                  <Td mono>{fmtInt(m.calls)}</Td>337                  <Td mono>{fmtInt(Number(m.input_tokens))}</Td>338                  <Td mono>{fmtInt(Number(m.output_tokens))}</Td>339                  <Td mono className={m.failures ? "text-danger" : "text-fg-subtle"}>{fmtInt(m.failures)}</Td>340                </tr>341              ))}342            </Table>343          ) : (344            <Empty>No LLM calls in 24 h (heuristics only).</Empty>345          )}346        </Panel>347        <Panel title="Live & cache" dense>348          <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2 text-[12.5px]">349            <Row k="WS clients" v={fmtInt(d.live.clients)} />350            <Row k="Published since start" v={fmtInt(d.live.published)} />351            <Row k="Cache entries" v={fmtInt(d.cache.entries)} />352          </dl>353          <div className="max-h-32 overflow-auto border-t border-line px-3 py-2">354            <div className="flex flex-wrap gap-1">{d.cache.keys.slice(0, 40).map((k) => <Chip key={k} className="font-mono">{k}</Chip>)}</div>355          </div>356        </Panel>357      </div>358359      <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">360        <Panel title="Top failing domains · 6 h" dense>361          {d.top_failing_domains.length ? (362            <Table head={["Host", "Failures", "Sensors", "Last error", "Last"]}>363              {d.top_failing_domains.map((f) => (364                <tr key={f.host}>365                  <Td mono><Link href={`/domain/${f.host}`} className="hover:underline">{f.host}</Link></Td>366                  <Td mono className="text-danger">{fmtInt(f.failures)}</Td>367                  <Td mono>{fmtInt(f.sensors)}</Td>368                  <Td className="max-w-[18rem] truncate font-mono text-[11px] text-fg-muted" ><span title={f.last_error ?? ""}>{f.last_error ?? "—"}</span></Td>369                  <Td mono className="whitespace-nowrap text-fg-subtle">{relTime(f.last_at)}</Td>370                </tr>371              ))}372            </Table>373          ) : (374            <Empty>No failures in the last 6 h.</Empty>375          )}376        </Panel>377        <Panel title="Slowest sensors" dense>378          {d.slowest_sensors.length ? (379            <Table head={["Sensor", "Source", "Connector", "Avg latency", "Runs"]}>380              {d.slowest_sensors.map((s) => (381                <tr key={s.id}>382                  <Td><Link href={`/sensor/${s.id}`} className="hover:underline">{s.name}</Link><div className="max-w-[14rem] truncate font-mono text-[10.5px] text-fg-subtle">{s.url}</div></Td>383                  <Td><Link href={`/source/${s.source_id}`} className="hover:underline">{s.source_id}</Link></Td>384                  <Td mono>{s.connector}</Td>385                  <Td mono className={s.avg_latency_ms > 10_000 ? "text-warn" : ""}>{fmtMs(s.avg_latency_ms)}</Td>386                  <Td mono>{fmtInt(s.total_runs)}</Td>387                </tr>388              ))}389            </Table>390          ) : (391            <Empty />392          )}393        </Panel>394      </div>395396      <Panel title="Recent errors · 1 h" dense>397        {d.recent_errors.length ? (398          <Table head={["At", "Sensor", "Source", "Connector", "HTTP", "Outcome", "Error"]}>399            {d.recent_errors.map((r, i) => (400              <tr key={`${r.sensor_id}-${i}`}>401                <Td mono className="whitespace-nowrap text-fg-subtle">{utcTime(r.started_at)}</Td>402                <Td><Link href={`/sensor/${r.sensor_id}`} className="font-mono text-[11.5px] hover:underline">{r.sensor_id}</Link></Td>403                <Td><Link href={`/source/${r.source_id}`} className="hover:underline">{r.source_id}</Link></Td>404                <Td mono>{r.connector}</Td>405                <Td mono>{r.http_status ?? "—"}</Td>406                <Td mono className="text-danger">{r.outcome}</Td>407                <Td className="max-w-[24rem] truncate font-mono text-[11px] text-fg-muted"><span title={r.error ?? ""}>{r.error ?? "—"}</span></Td>408              </tr>409            ))}410          </Table>411        ) : (412          <Empty>No errors in the last hour.</Empty>413        )}414      </Panel>415    </>416  );417}418419function Row({ k, v, tone = "" }: { k: string; v: ReactNode; tone?: string }) {420  return (421    <>422      <dt className="text-fg-subtle">{k}</dt>423      <dd className={`min-w-0 break-words text-right font-mono text-[12px] tabular ${tone}`}>{v}</dd>424    </>425  );426}427428function Spark({ label, values, tone }: { label: string; values: number[]; tone: "signal" | "hot" | "info" }) {429  const last = values[values.length - 1] ?? 0;430  const max = Math.max(...values, 0);431  return (432    <div className="rounded-md border border-line bg-panel-2 p-2">433      <div className="flex items-baseline justify-between">434        <span className="label">{label}</span>435        <span className="font-mono text-[11px] text-fg-subtle tabular">last h {fmtInt(last)} · max {fmtInt(max)}</span>436      </div>437      <div className="mt-1 w-full overflow-hidden">438        <Sparkline values={values} width={360} height={40} tone={tone} />439      </div>440    </div>441  );442}443444// ---------------------------------------------------------------------------------------445// Tools446// ---------------------------------------------------------------------------------------447448type Json = Record<string, unknown>;449450function Result({ r }: { r: { ok: boolean; body: Json | null; error?: string } | null }) {451  if (!r) return null;452  return (453    <div className={`rounded-md border px-2 py-1.5 text-[12px] ${r.ok ? "border-ok/40 bg-ok/10" : "border-danger/40 bg-danger/10"}`}>454      {r.error && <div className="font-mono font-semibold text-danger">{r.error}</div>}455      {r.body && <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-fg-muted">{JSON.stringify(r.body, null, 2)}</pre>}456    </div>457  );458}459460function useAction() {461  const [busy, setBusy] = useState(false);462  const [res, setRes] = useState<{ ok: boolean; body: Json | null; error?: string } | null>(null);463  const run = async (fn: () => Promise<Json>): Promise<Json | null> => {464    setBusy(true);465    setRes(null);466    try {467      const body = await fn();468      setRes({ ok: true, body });469      return body;470    } catch (e) {471      setRes({ ok: false, body: e instanceof AdminError ? e.body : null, error: gateMessage(e) });472      return null;473    } finally {474      setBusy(false);475    }476  };477  return { busy, res, run };478}479480const input = "h-8 min-w-0 rounded-md border border-line bg-panel px-2 font-mono text-[12px]";481const btn = "inline-flex h-8 items-center gap-1 rounded-md border border-line bg-panel-2 px-2.5 text-[12px] hover:border-line-strong disabled:opacity-50";482483function Tools({ connectors, onDone }: { connectors: Ops["connectors"]; onDone: () => void }) {484  return (485    <>486      <h2 className="label mt-2">Tools</h2>487      <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">488        <TestConnector connectors={connectors} />489        <SensorActions onDone={onDone} />490        <SourceAndBulk onDone={onDone} />491        <Import onDone={onDone} />492      </div>493    </>494  );495}496497function TestConnector({ connectors }: { connectors: Ops["connectors"] }) {498  const [f, setF] = useState({ url: "", sensor_id: "", connector: "http", type: "HTML", config: "{}" });499  const { busy, res, run } = useAction();500  const preview = res?.body?.normalized as Json | undefined;501  const meta = res?.body?.meta as Json | undefined;502  const submit = (): void => {503    let config: Json = {};504    try {505      config = f.config.trim() ? (JSON.parse(f.config) as Json) : {};506    } catch {507      void run(() => Promise.reject(new Error("config is not valid JSON")));508      return;509    }510    void run(() => adminFetch<Json>("/api/v1/admin/sensors/test", { method: "POST", body: JSON.stringify({ ...(f.sensor_id.trim() ? { sensor_id: f.sensor_id.trim() } : { url: f.url.trim() }), connector: f.connector, type: f.type, config }) }));511  };512  return (513    <Panel title="Test connector · dry run, nothing persisted">514      <form515        className="flex flex-col gap-2 text-[12.5px]"516        onSubmit={(e) => {517          e.preventDefault();518          submit();519        }}520      >521        <input value={f.url} onChange={(e) => setF({ ...f, url: e.target.value })} placeholder="https://example.com/feed.xml" aria-label="URL" className={input} />522        <input value={f.sensor_id} onChange={(e) => setF({ ...f, sensor_id: e.target.value })} placeholder="… or an existing sensor id (overrides URL)" aria-label="Sensor id" className={input} />523        <div className="grid grid-cols-2 gap-2">524          <select value={f.connector} onChange={(e) => setF({ ...f, connector: e.target.value })} aria-label="Connector" className={input}>525            {(connectors.length ? connectors.map((c) => c.key) : ["http"]).map((k) => (526              <option key={k} value={k}>{k}</option>527            ))}528          </select>529          <select value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} aria-label="Sensor type" className={input}>530            {SENSOR_TYPES.map((t) => (531              <option key={t} value={t}>{t}</option>532            ))}533          </select>534        </div>535        <textarea value={f.config} onChange={(e) => setF({ ...f, config: e.target.value })} rows={2} aria-label="Config JSON" placeholder='{"selector": "main"}' className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px]" />536        <button type="submit" disabled={busy || (!f.url.trim() && !f.sensor_id.trim())} className={btn}><Play className="size-3.5" /> {busy ? "Fetching…" : "Run test"}</button>537      </form>538      {res && (539        <div className="mt-2 flex flex-col gap-2 text-[12px]">540          <div className="flex flex-wrap items-center gap-2">541            <Chip tone={res.ok && res.body?.ok ? "ok" : "danger"} className="font-mono">{res.ok && res.body?.ok ? "OK" : `FAILED${typeof res.body?.stage === "string" ? ` · ${res.body.stage}` : ""}`}</Chip>542            {meta && <span className="font-mono text-[11px] text-fg-subtle tabular">HTTP {String(meta.status ?? "—")} · {String(meta.contentType ?? "—")} · {fmtBytes(Number(meta.contentLength ?? 0))} · {String(res.body?.fetch_ms ?? "—")} ms · {String(meta.redirects ?? 0)} redirects</span>}543          </div>544          {preview && (545            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-md border border-line bg-panel-2 p-2 text-[12px]">546              <Row k="Mode" v={String(preview.mode ?? "—")} />547              <Row k="Title" v={String(preview.title ?? "—")} />548              <Row k="Items" v={preview.items === null || preview.items === undefined ? "—" : String(preview.items)} />549              <Row k="Extraction confidence" v={typeof preview.extractionConfidence === "number" ? `${Math.round(preview.extractionConfidence * 100)}%` : "—"} />550              <Row k="Canonical hash" v={String(preview.canonicalHash ?? "").slice(0, 16)} />551              <Row k="Published" v={String(preview.publishedAt ?? "—")} />552            </dl>553          )}554          {preview && typeof preview.text_preview === "string" && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{preview.text_preview}</pre>}555          {preview && typeof preview.json_preview === "string" && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{preview.json_preview}</pre>}556          {preview && Array.isArray(preview.sample_items) && preview.sample_items.length > 0 && <pre className="max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-line bg-panel-2 p-2 font-mono text-[11px] leading-4 text-fg-muted">{JSON.stringify(preview.sample_items, null, 1)}</pre>}557          {(!res.ok || !res.body?.ok) && <Result r={{ ok: false, body: (res.body?.error as Json | undefined) ?? res.body, error: res.error }} />}558        </div>559      )}560    </Panel>561  );562}563564function SensorActions({ onDone }: { onDone: () => void }) {565  const [id, setId] = useState("");566  const [tier, setTier] = useState("");567  const [priority, setPriority] = useState("");568  const [interval, setInterval_] = useState("");569  const { busy, res, run } = useAction();570  const act = (a: "run-now" | "enable" | "disable"): void => {571    void run(() => adminFetch<Json>(`/api/v1/admin/sensors/${encodeURIComponent(id.trim())}/${a}`, { method: "POST" })).then(onDone);572  };573  const patch = (): void => {574    const body: Json = {};575    if (tier) body.tier = tier;576    if (priority !== "") body.priority = Number(priority);577    if (interval !== "") body.base_interval_seconds = Number(interval);578    void run(() => adminFetch<Json>(`/api/v1/admin/sensors/${encodeURIComponent(id.trim())}`, { method: "PATCH", body: JSON.stringify(body) })).then(onDone);579  };580  return (581    <Panel title="Sensor actions">582      <div className="flex flex-col gap-2 text-[12.5px]">583        <input value={id} onChange={(e) => setId(e.target.value)} placeholder="sensor id (e.g. openai_pricing)" aria-label="Sensor id" className={input} />584        <div className="flex flex-wrap gap-1">585          <button type="button" disabled={busy || !id.trim()} onClick={() => act("run-now")} className={btn}><Play className="size-3.5" /> run now</button>586          <button type="button" disabled={busy || !id.trim()} onClick={() => act("enable")} className={btn}>enable</button>587          <button type="button" disabled={busy || !id.trim()} onClick={() => act("disable")} className={`${btn} text-danger`}>disable</button>588          <Link href={id.trim() ? `/sensor/${encodeURIComponent(id.trim())}` : "#"} className={`${btn} ${id.trim() ? "" : "pointer-events-none opacity-50"}`}>open →</Link>589        </div>590        <div className="grid grid-cols-3 gap-2">591          <select value={tier} onChange={(e) => setTier(e.target.value)} aria-label="Tier" className={input}>592            <option value="">tier …</option>593            {TIERS.map((t) => (594              <option key={t} value={t}>tier {t}</option>595            ))}596          </select>597          <select value={priority} onChange={(e) => setPriority(e.target.value)} aria-label="Priority" className={input}>598            <option value="">priority …</option>599            {[0, 1, 2, 3].map((p) => (600              <option key={p} value={p}>P{p}</option>601            ))}602          </select>603          <input value={interval} onChange={(e) => setInterval_(e.target.value)} inputMode="numeric" placeholder="interval s" aria-label="Base interval seconds" className={input} />604        </div>605        <button type="button" disabled={busy || !id.trim() || (!tier && priority === "" && interval === "")} onClick={patch} className={btn}>apply patch</button>606        <Result r={res} />607      </div>608    </Panel>609  );610}611612function SourceAndBulk({ onDone }: { onDone: () => void }) {613  const [sid, setSid] = useState("");614  const [ids, setIds] = useState("");615  const [action, setAction] = useState<"enable" | "disable" | "run-now">("run-now");616  const src = useAction();617  const bulk = useAction();618  const list = ids.split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);619  return (620    <Panel title="Source actions & bulk">621      <div className="flex flex-col gap-2 text-[12.5px]">622        <input value={sid} onChange={(e) => setSid(e.target.value)} placeholder="source id (e.g. openai)" aria-label="Source id" className={input} />623        <div className="flex flex-wrap gap-1">624          {(["run-now", "enable", "disable"] as const).map((a) => (625            <button key={a} type="button" disabled={src.busy || !sid.trim()} onClick={() => void src.run(() => adminFetch<Json>(`/api/v1/admin/sources/${encodeURIComponent(sid.trim())}/${a}`, { method: "POST" })).then(onDone)} className={`${btn} ${a === "disable" ? "text-danger" : ""}`}>626              {a === "run-now" && <Play className="size-3.5" />} {a}627            </button>628          ))}629          <Link href={sid.trim() ? `/source/${encodeURIComponent(sid.trim())}` : "#"} className={`${btn} ${sid.trim() ? "" : "pointer-events-none opacity-50"}`}>open →</Link>630        </div>631        <Result r={src.res} />632        <div className="label mt-2">Bulk sensor action</div>633        <textarea value={ids} onChange={(e) => setIds(e.target.value)} rows={3} placeholder="sensor ids, one per line or comma-separated" aria-label="Sensor ids" className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px]" />634        <div className="flex flex-wrap items-center gap-1">635          <select value={action} onChange={(e) => setAction(e.target.value as typeof action)} aria-label="Bulk action" className={input}>636            <option value="run-now">run now</option>637            <option value="enable">enable</option>638            <option value="disable">disable</option>639          </select>640          <button type="button" disabled={bulk.busy || !list.length} onClick={() => void bulk.run(() => adminFetch<Json>("/api/v1/admin/sensors/bulk", { method: "POST", body: JSON.stringify({ ids: list, action }) })).then(onDone)} className={btn}>641            apply to <span className="font-mono tabular">{list.length}</span>642          </button>643        </div>644        <Result r={bulk.res} />645      </div>646    </Panel>647  );648}649650interface ImportReport {651  dry_run: boolean;652  valid?: number;653  invalid: number;654  imported_sources?: number;655  extended_sources?: number;656  sensors?: number;657  report: { id?: string; ok: boolean; extend: boolean; issues?: string[]; sensors?: number }[];658}659660function Import({ onDone }: { onDone: () => void }) {661  const [doc, setDoc] = useState("");662  const [report, setReport] = useState<ImportReport | null>(null);663  const [checkedDoc, setCheckedDoc] = useState<string | null>(null);664  const { busy, res, run } = useAction();665  const isJson = /^\s*[[{]/.test(doc);666  const send = async (dry: boolean): Promise<void> => {667    const body = await run(() => adminFetch<Json>(`/api/v1/admin/sources/import${dry ? "?dry_run=1" : ""}`, { method: "POST", headers: { "content-type": isJson ? "application/json" : "text/yaml" }, body: doc }));668    if (body) {669      setReport(body as unknown as ImportReport);670      setCheckedDoc(dry ? doc : null);671      if (!dry) onDone();672    } else setReport(null);673  };674  const canImport = report?.dry_run === true && checkedDoc === doc && (report.valid ?? 0) > 0;675  return (676    <Panel title="Import sources · YAML or JSON">677      <div className="flex flex-col gap-2 text-[12.5px]">678        <textarea value={doc} onChange={(e) => setDoc(e.target.value)} rows={7} spellCheck={false} aria-label="Registry document" placeholder={"sources:\n  - id: example\n    name: Example\n    domain: example.com\n    tier: C\n    categories: [web]\n    sensors:\n      - name: home\n        type: HTML\n        url: https://example.com/"} className="rounded-md border border-line bg-panel px-2 py-1 font-mono text-[12px] leading-4" />679        <div className="flex flex-wrap items-center gap-1">680          <Chip className="font-mono">{isJson ? "application/json" : "text/yaml"}</Chip>681          <button type="button" disabled={busy || !doc.trim()} onClick={() => void send(true)} className={btn}>dry run</button>682          <button type="button" disabled={busy || !canImport} onClick={() => void send(false)} className={`${btn} ${canImport ? "border-signal/50 text-signal" : ""}`} title={canImport ? "Write the validated document" : "Run a successful dry run first"}>import</button>683          <span className="text-[11px] text-fg-subtle">Same schema as <code>config/sources.d/*.yaml</code>; <code>extend: true</code> adds to an existing source.</span>684        </div>685        {res && !res.ok && <Result r={res} />}686        {report && (687          <div>688            <div className="mb-1 flex flex-wrap gap-1 text-[11.5px]">689              <Chip tone={report.dry_run ? "info" : "ok"} className="font-mono">{report.dry_run ? "DRY RUN" : "IMPORTED"}</Chip>690              {report.valid !== undefined && <Chip tone="ok">{report.valid} valid</Chip>}691              {report.invalid > 0 && <Chip tone="danger">{report.invalid} invalid</Chip>}692              {report.imported_sources !== undefined && <Chip>{report.imported_sources} sources · {report.extended_sources ?? 0} extended · {report.sensors ?? 0} sensors</Chip>}693            </div>694            <div className="overflow-hidden rounded-md border border-line">695              <Table head={["Source", "Mode", "Result", "Sensors / issues"]}>696                {report.report.map((r, i) => (697                  <tr key={`${r.id ?? "?"}-${i}`}>698                    <Td mono>{r.id ?? <span className="text-fg-subtle">(no id)</span>}</Td>699                    <Td mono className="text-fg-subtle">{r.extend ? "extend" : "new"}</Td>700                    <Td>{r.ok ? <Chip tone="ok">OK</Chip> : <Chip tone="danger">FAIL</Chip>}</Td>701                    <Td className="text-[12px]">{r.ok ? <span className="font-mono">{r.sensors ?? 0} sensors</span> : <ul className="list-disc pl-4 text-danger">{(r.issues ?? []).map((x, j) => <li key={j} className="font-mono text-[11px]">{x}</li>)}</ul>}</Td>702                  </tr>703                ))}704              </Table>705            </div>706          </div>707        )}708      </div>709    </Panel>710  );711}712