1import Link from 'next/link';2import { requireAdmin } from '@/lib/admin/auth';3import { connectorsOverview } from '@/lib/admin/queries';4import { connectorAction } from '@/lib/admin/actions';5import { AdminShell, ActionButton, StatusPill, fmtTs, n } from '@/components/admin/shell';6import { Table, th, td, tdNum } from '@/components/ui/primitives';78function pct(v: unknown): string {9 const x = Number(v);10 return Number.isFinite(x) ? `${Math.round(x * 100)}%` : '—';11}1213export default async function ConnectorsPage() {14 await requireAdmin();15 const rows = await connectorsOverview();16 return (17 <AdminShell current="/admin/connectors" title="Connector control center" subtitle={`${rows.length} connectors registered · actions enqueue jobs on the crawl.run queue consumed by the worker`}>18 <div className="card">19 <Table>20 <thead>21 <tr>22 <th className={th}>Connector</th>23 <th className={th}>Health</th>24 <th className={th}>Status</th>25 <th className={`${th} text-right`}>Pages/min</th>26 <th className={`${th} text-right`}>Errors</th>27 <th className={th}>Last crawl</th>28 <th className={`${th} text-right`}>Records 24h</th>29 <th className={`${th} text-right`}>Dup.</th>30 <th className={`${th} text-right`}>FC rate</th>31 <th className={`${th} text-right`}>SF rate</th>32 <th className={`${th} text-right`}>Parser conf.</th>33 <th className={th}>Schema</th>34 <th className={th}>Actions</th>35 </tr>36 </thead>37 <tbody>38 {rows.length === 0 ? <tr><td className={td} colSpan={13}><span className="text-subtle">No connectors in the registry yet. Add meta.json files under connectors/ and run pnpm db:seed.</span></td></tr> : null}39 {rows.map((r) => {40 const health = (r.health ?? {}) as Record<string, unknown>;41 const anomalies = Array.isArray(r.last_anomalies) ? (r.last_anomalies as string[]) : [];42 const drift = anomalies.some((a) => /schema|selector|missing_field|redesign/.test(a));43 return (44 <tr key={String(r.id)} className="align-top">45 <td className={td}>46 <Link href={`/admin/connectors/${String(r.id)}`} className="font-medium hover:underline">{String(r.display_name)}</Link>47 <div className="text-[11px] text-subtle">{String(r.id)} · {(r.engine_priority as string[]).join('→')} · {String(r.priority)} · every {n(r.refresh_frequency_minutes)} min</div>48 </td>49 <td className={td}><StatusPill status={(r.health_status as string) ?? 'unknown'} />{r.health_at ? <div className="text-[10px] text-subtle">{fmtTs(r.health_at)}</div> : null}</td>50 <td className={td}><StatusPill status={String(r.status)} />{r.last_run_status ? <div className="text-[10px] text-subtle">run: {String(r.last_run_status)}</div> : null}</td>51 <td className={tdNum}>{n(r.pages_per_min, 1)}</td>52 <td className={`${tdNum} ${r.last_error ? 'text-loss' : ''}`} title={String(r.last_error ?? '')}>{r.last_error ? '1' : '0'}{anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}</td>53 <td className={td}>{fmtTs(r.last_started_at)}<div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at)}</div></td>54 <td className={tdNum}>{n(r.records_24h)}<div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm.</div></td>55 <td className={tdNum}>{n(r.duplicates_24h)}</td>56 <td className={tdNum}>{pct(health.firecrawl_success_rate)}</td>57 <td className={tdNum}>{pct(health.scrapfly_fallback_rate)}</td>58 <td className={tdNum}>{pct(r.parser_confidence)}</td>59 <td className={td}>v{String(r.schema_version)}{drift ? <div className="text-[10px] text-alert">drift?</div> : null}</td>60 <td className={td}>61 <div className="flex flex-wrap gap-1">62 {(['run', 'probe', 'recrawl', 'retry'] as const).map((a) => (63 <form key={a} action={connectorAction}>64 <input type="hidden" name="id" value={String(r.id)} />65 <input type="hidden" name="action" value={a} />66 <ActionButton label={a} />67 </form>68 ))}69 <form action={connectorAction}>70 <input type="hidden" name="id" value={String(r.id)} />71 <input type="hidden" name="action" value={r.status === 'paused' ? 'resume' : 'pause'} />72 <ActionButton label={r.status === 'paused' ? 'resume' : 'pause'} tone={r.status === 'paused' ? 'primary' : 'danger'} />73 </form>74 <Link href={`/admin/connectors/${String(r.id)}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">inspect</Link>75 </div>76 </td>77 </tr>78 );79 })}80 </tbody>81 </Table>82 </div>83 <p className="mt-3 text-[11px] text-subtle">run = incremental crawl · probe = 25-record smoke test · recrawl = backfill mode · retry = re-run from the last cursor. FC = Firecrawl success rate, SF = share of pages that needed Scrapfly (from the 24 h health snapshot).</p>84 </AdminShell>85 );86}87