"use client"; import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge, Button, EmptyState, RelativeTime, SearchInput, SkeletonRows, Table, TBody, Td, Th, THead, Tr } from "@/components/ui"; import { cn } from "@/components/ui/cn"; import { DASH, fmtInt } from "@/lib/format"; import { SOURCE_KIND_LABEL } from "@/lib/labels"; import type { ConnectorHealthDTO } from "@/lib/admin-api"; import { PauseResumeButton, RunForm, ScheduleEditor } from "./connector-actions"; import { ErrorNote, fmtMs, fmtPct01, HEALTH, HealthBadge, HealthDot, Mono, RefreshButton, Segmented, Select } from "./primitives"; import { AdminPageHeader } from "./shell"; import { useToast } from "./toast"; import { useAdminMutation, useAdminQuery } from "./use-admin-query"; import { useUrlState } from "./use-url-state"; /* ------------------------------------------------------------------------------------------ Connector HEALTH CENTER (§11–12): one row per connector with the crawl / extraction / publication counters of the last window, a spec status badge, quarantine and reprocess controls. ------------------------------------------------------------------------------------------ */ type SortKey = | "id" | "kind" | "health" | "lastRunAt" | "lastSuccessAt" | "lastFailureAt" | "nextRunAt" | "urlsDiscovered" | "urlsFetched" | "newDocs" | "changedDocs" | "extracted" | "recordsCreated" | "recordsModified" | "rejectedClaims" | "httpErrors" | "antiBotEscalations" | "scrapflyRequests" | "firecrawlRequests" | "failed" | "extractionSuccess" | "avgResponseMs" | "credits" | "parserVersion"; const HEALTH_ORDER: Record = { blocked: 0, failing: 1, schema_change: 2, degraded: 3, quarantine: 4, no_new_content: 5, ok: 6, never_run: 7, paused: 8 }; const HEALTH_FILTERS = ["all", "ok", "degraded", "blocked", "schema_change", "no_new_content", "failing", "quarantine", "paused", "never_run", "enabled", "disabled"] as const; type HealthFilter = (typeof HEALTH_FILTERS)[number]; const COLUMN_SETS = ["compact", "full"] as const; type ColumnSet = (typeof COLUMN_SETS)[number]; function num(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } function sortValue(c: ConnectorHealthDTO, k: SortKey): number | string { switch (k) { case "credits": return c.cost.credits; case "health": return HEALTH_ORDER[c.health] ?? 9; case "lastRunAt": case "nextRunAt": case "lastSuccessAt": case "lastFailureAt": return c[k] ? new Date(c[k] as string).getTime() : -1; case "id": case "kind": case "parserVersion": return c[k]; default: { const v = num(c[k]); return v ?? -1; } } } /** A counter cell: "—" when the API does not carry the field (older API), highlighted when non-zero and `hot`. */ function Count({ v, hot, className }: { v: number | null | undefined; hot?: "accent" | "warn" | "danger"; className?: string }) { if (v === null || v === undefined) return {DASH}; return 0 && hot === "accent" && "text-accent-ink", v > 0 && hot === "warn" && "text-warn", v > 0 && hot === "danger" && "text-danger", className)}>{fmtInt(v)}; } export function ConnectorsTable() { const q = useAdminQuery("connectors", { refreshMs: 60_000, timeoutMs: 30_000 }); const [params, setParams] = useUrlState(); const kind = params.kind ?? ""; const health = (HEALTH_FILTERS.includes(params.health as HealthFilter) ? params.health : "all") as HealthFilter; const columns = (COLUMN_SETS.includes(params.cols as ColumnSet) ? params.cols : "compact") as ColumnSet; const full = columns === "full"; const search = params.q ?? ""; const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "health", dir: "asc" }); const [open, setOpen] = useState<{ id: string; mode: "run" | "schedule" } | null>(null); const { busy, run } = useAdminMutation(); const toast = useToast(); const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null); const list = useMemo(() => q.data ?? [], [q.data]); const counts = useMemo(() => { const c: Record = { all: list.length, enabled: 0, disabled: 0 }; for (const x of list) { c[x.health] = (c[x.health] ?? 0) + 1; if (x.enabled) c.enabled!++; else c.disabled!++; } return c; }, [list]); const kinds = useMemo(() => [...new Set(list.map((c) => c.kind))].sort(), [list]); const rows = useMemo(() => { const s = search.trim().toLowerCase(); const out = list.filter((c) => (!kind || c.kind === kind) && (health === "all" || (health === "enabled" ? c.enabled : health === "disabled" ? !c.enabled : c.health === health)) && (!s || c.id.includes(s) || c.sourceName.toLowerCase().includes(s) || c.domain.toLowerCase().includes(s))); out.sort((a, b) => { const va = sortValue(a, sort.key); const vb = sortValue(b, sort.key); const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb)); return (sort.dir === "asc" ? cmp : -cmp) || a.id.localeCompare(b.id); }); return out; }, [list, kind, health, search, sort]); const totals = useMemo(() => { const t = { urlsFetched: 0, newDocs: 0, changedDocs: 0, recordsCreated: 0, rejectedClaims: 0, httpErrors: 0, antiBotEscalations: 0, scrapfly: 0, firecrawl: 0 }; for (const c of rows) { t.urlsFetched += c.urlsFetched ?? c.fetched ?? 0; t.newDocs += c.newDocs ?? 0; t.changedDocs += c.changedDocs ?? c.changed ?? 0; t.recordsCreated += c.recordsCreated ?? 0; t.rejectedClaims += c.rejectedClaims ?? 0; t.httpErrors += c.httpErrors ?? 0; t.antiBotEscalations += c.antiBotEscalations ?? 0; t.scrapfly += c.scrapflyRequests ?? c.cost.scrapfly; t.firecrawl += c.firecrawlRequests ?? c.cost.firecrawl; } return t; }, [rows]); const th = (key: SortKey, label: string, numeric?: boolean, title?: string) => ( setSort((s) => ({ key, dir: s.key === key && s.dir === "asc" ? "desc" : s.key === key ? "asc" : numeric ? "desc" : "asc" }))}> {label} ); /** Sequential POST run {task: reprocess} for every enabled, non-quarantined connector in the current view. */ const reprocessAll = async () => { const targets = rows.filter((c) => c.enabled && !c.quarantine && c.health !== "paused"); if (!targets.length) return toast({ tone: "warn", title: "Nothing to reprocess", description: "No enabled connector in the current view." }); if (!window.confirm(`Enqueue a REPROCESS run (re-extract stored bodies, no network fetch) for ${targets.length} enabled connector${targets.length === 1 ? "" : "s"} in the current view?`)) return; setBulk({ done: 0, total: targets.length }); let ok = 0; let failed = 0; for (const c of targets) { const r = await run<{ enqueued: boolean }>(`reprocess-all`, `connectors/${encodeURIComponent(c.id)}/run`, { body: { task: "reprocess" } }); if (r.ok) ok++; else failed++; setBulk((b) => (b ? { ...b, done: b.done + 1 } : b)); } setBulk(null); toast({ tone: failed ? "warn" : "ok", title: `Reprocess enqueued for ${ok} connector${ok === 1 ? "" : "s"}`, description: failed ? `${failed} failed to enqueue` : undefined }); void q.refresh(); }; return (
void q.refresh()} busy={q.refreshing} updatedAt={q.updatedAt} /> } /> void q.refresh()} className="mb-3" />
setParams({ health: v === "all" ? null : v })} options={HEALTH_FILTERS.map((h) => ({ value: h, label: h === "all" ? "All" : h === "enabled" || h === "disabled" ? h[0]!.toUpperCase() + h.slice(1) : (HEALTH[h]?.label ?? h).toLowerCase().replace(/^./, (m) => m.toUpperCase()), count: counts[h] ?? 0 }))} />
setParams({ q: v || null })} placeholder="Search id, name, domain" wrapperClassName="min-w-[180px] flex-1" /> setParams({ cols: v === "compact" ? null : v })} options={[{ value: "compact", label: "Compact" }, { value: "full", label: "All columns" }]} />
{q.data && rows.length > 0 && (

In view: {fmtInt(totals.urlsFetched)} URLs fetched · {fmtInt(totals.newDocs)} new docs · {fmtInt(totals.changedDocs)} changed · {fmtInt(totals.recordsCreated)} records created · 0 ? "text-warn" : "text-ink-2")}>{fmtInt(totals.rejectedClaims)} rejected claims · 0 ? "text-danger" : "text-ink-2")}>{fmtInt(totals.httpErrors)} HTTP errors · {fmtInt(totals.antiBotEscalations)} anti-bot escalations · Scrapfly {fmtInt(totals.scrapfly)} / Firecrawl {fmtInt(totals.firecrawl)} requests.

)} {q.loading && !q.data ? ( ) : rows.length === 0 ? ( ) : (
{th("id", "Source")} {full && } {th("kind", "Kind")} {th("health", "Status")} {th("lastRunAt", "Last crawl")} {full && th("lastSuccessAt", "Last success")} {th("lastFailureAt", "Last failure")} {full && th("nextRunAt", "Next")} {th("urlsDiscovered", "URLs disc.", true, "URLs discovered")} {th("urlsFetched", "URLs fetched", true)} {th("newDocs", "New docs", true)} {th("changedDocs", "Changed", true, "Changed documents")} {th("extracted", "Extract.", true, "Extractions")} {full && th("extractionSuccess", "Extract %", true, "Extraction success rate")} {th("recordsCreated", "Rec. created", true, "Records created")} {full && th("recordsModified", "Rec. modified", true, "Records modified")} {th("rejectedClaims", "Rej. claims", true, "Rejected claims")} {th("httpErrors", "HTTP err.", true, "HTTP errors")} {full && th("antiBotEscalations", "Anti-bot", true, "Anti-bot escalations (L2+)")} {full && th("scrapflyRequests", "Scrapfly", true, "Scrapfly requests")} {full && th("firecrawlRequests", "Firecrawl", true, "Firecrawl requests")} {th("avgResponseMs", "Avg latency", true)} {full && th("credits", "Credits", true, "Premium credits (7 d)")} {full && th("parserVersion", "Parser")} {rows.map((c) => { const isOpen = open?.id === c.id; return ( setOpen((o) => (o?.id === c.id && o.mode === mode ? null : { id: c.id, mode }))} onChanged={() => void q.refresh()} mutate={q.mutate} /> ); })}
{""}DomainActions
)}

Status: HEALTHY · DEGRADED (partial failures / slow) · BLOCKED (anti-bot or robots) · SCHEMA CHANGE (parser yields nothing on changed pages) · NO NEW CONTENT · FAILED · QUARANTINE (extracts and previews, publishes nothing) · PAUSED · NEVER RUN. Counters that the API does not report yet show “—”.

); } function ConnectorRow({ c, full, open, onToggle, onChanged, mutate }: { c: ConnectorHealthDTO; full: boolean; open: "run" | "schedule" | null; onToggle: (m: "run" | "schedule") => void; onChanged: () => void; mutate: (fn: (prev: ConnectorHealthDTO[] | null) => ConnectorHealthDTO[] | null) => void }) { const cost = c.cost; const { busy, run } = useAdminMutation(); const toast = useToast(); const quarantined = Boolean(c.quarantine) || c.health === "quarantine"; const colSpan = full ? 27 : 18; const toggleQuarantine = async () => { const on = !quarantined; if (on && !window.confirm(`Quarantine ${c.id}? It will keep extracting and previewing but publish nothing until released.`)) return; const r = await run<{ id: string; quarantine: boolean; health: string }>("quarantine", `connectors/${encodeURIComponent(c.id)}/quarantine`, { body: { on } }); if (r.ok) { toast({ tone: "ok", title: on ? `${c.id} quarantined` : `${c.id} released from quarantine` }); mutate((prev) => prev?.map((x) => (x.id === c.id ? { ...x, quarantine: r.data.quarantine, health: r.data.health as ConnectorHealthDTO["health"] } : x)) ?? prev); } else toast({ tone: "danger", title: "Quarantine change failed", description: r.error }); }; const reprocess = async () => { const r = await run<{ enqueued: boolean; job?: { id?: string } }>("reprocess", `connectors/${encodeURIComponent(c.id)}/run`, { body: { task: "reprocess" } }); if (r.ok) toast({ tone: "ok", title: `Reprocess enqueued · ${c.id}`, description: r.data.job?.id ? `job ${r.data.job.id}` : "re-extracts stored bodies, no network fetch" }); else toast({ tone: "danger", title: "Reprocess failed to enqueue", description: r.error }); }; return ( <> {c.id}
{c.sourceName} {!full && · {c.domain}}
{full && ( {c.domain} )} {SOURCE_KIND_LABEL[c.kind] ?? c.kind}
{!c.enabled && disabled} {c.mode} {(c.consecutiveFailures ?? 0) > 0 && ( ×{c.consecutiveFailures} )} {c.blockedSince && ( blocked )}
{c.lastRunAt ? : DASH} {full && {c.lastSuccessAt ? : DASH}} c.lastSuccessAt ? "text-danger" : "text-ink-3")}>{c.lastFailureAt ? : DASH} {full && {c.nextRunAt ? : DASH}} {full && {fmtPct01(c.extractionSuccess)}} {full && ( )} {full && ( )} {full && ( )} {full && ( )} {c.avgResponseMs != null ? fmtMs(c.avgResponseMs) : DASH} {full && ( {cost.credits > 0 ? {fmtInt(cost.credits)} : 0} )} {full && ( {c.parserVersion} )}
mutate((prev) => prev?.map((x) => (x.id === c.id ? { ...x, health: paused ? "paused" : x.lastRunAt ? "ok" : "never_run", enabled: !paused && x.enabled } : x)) ?? prev)} />
{open && ( {open === "run" ? : mutate((prev) => prev?.map((x) => (x.id === c.id ? { ...x, schedule } : x)) ?? prev)} />} )} ); }