TypeScript 61.9%
HTML 37.2%
SQL 0.7%
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 { HealthLabel, SourceLogo, Pct, Progress, Chip } from '@/components/admin/connector-bits';7import { Table, th, td, tdNum } from '@/components/ui/primitives';89type Row = Record<string, unknown>;1011function str(v: unknown): string {12 return v === null || v === undefined ? '' : String(v);13}1415/** Connector explorer (SPEC §29): every connector with source, geography, acquisition, health, usage, backfill and actions. */16export default async function ConnectorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {17 await requireAdmin();18 const sp = await searchParams;19 const rowsAll = (await connectorsOverview()) as Row[];20 const q = (sp.q ?? '').toLowerCase();21 const status = sp.status ?? '';22 const category = sp.category ?? '';23 const country = sp.country ?? '';24 const rows = rowsAll.filter((r) => {25 const meta = (r.meta ?? {}) as Row;26 if (status && String(r.health_status ?? 'unknown') !== status && String(r.status) !== status) return false;27 if (category && !((r.categories as string[]) ?? []).includes(category)) return false;28 if (country && String(meta.country ?? (r.regions as string[])?.[0] ?? '') !== country) return false;29 if (q && !`${str(r.id)} ${str(r.display_name)} ${str(meta.domain)} ${((r.categories as string[]) ?? []).join(' ')}`.toLowerCase().includes(q)) return false;30 return true;31 });32 const counts = rowsAll.reduce<Record<string, number>>((acc, r) => {33 const k = String(r.health_status ?? 'unknown');34 acc[k] = (acc[k] ?? 0) + 1;35 return acc;36 }, {});37 const categories = [...new Set(rowsAll.flatMap((r) => (r.categories as string[]) ?? []))].sort();38 const countries = [...new Set(rowsAll.map((r) => String(((r.meta ?? {}) as Row).country ?? (r.regions as string[])?.[0] ?? 'global')))].sort();3940 return (41 <AdminShell42 current="/admin/connectors"43 title="Connector explorer"44 subtitle={45 <>46 {rowsAll.length} connectors · {Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · ')} · <Link href="/admin/coverage" className="underline">coverage dashboard</Link> · <Link href="/api/admin/connectors" className="underline">JSON</Link>47 </>48 }49 actions={50 <form className="flex flex-wrap items-center gap-1 text-xs" method="get">51 <input name="q" defaultValue={sp.q ?? ''} placeholder="search id, domain, category" className="rounded-md border border-border bg-sunken px-2 py-1 text-xs" />52 <select name="status" defaultValue={status} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">53 <option value="">any health</option>54 {['healthy', 'degraded', 'failing', 'disabled', 'paused', 'maintenance', 'unknown'].map((s) => (55 <option key={s} value={s}>{s}</option>56 ))}57 </select>58 <select name="category" defaultValue={category} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">59 <option value="">any category</option>60 {categories.map((c) => (61 <option key={c} value={c}>{c}</option>62 ))}63 </select>64 <select name="country" defaultValue={country} className="rounded-md border border-border bg-sunken px-2 py-1 text-xs">65 <option value="">any country</option>66 {countries.map((c) => (67 <option key={c} value={c}>{c}</option>68 ))}69 </select>70 <button type="submit" className="rounded-md border border-border px-2 py-1 text-xs hover:bg-inset">Filter</button>71 </form>72 }73 >74 <div className="card overflow-x-auto">75 <Table>76 <thead>77 <tr>78 <th className={th}>Source</th>79 <th className={th}>Category · country</th>80 <th className={th}>Health</th>81 <th className={th}>Status</th>82 <th className={th}>Acquisition</th>83 <th className={th}>Last sync</th>84 <th className={`${th} text-right`}>Items 24h</th>85 <th className={`${th} text-right`}>Errors</th>86 <th className={`${th} text-right`}>FC · SF credits</th>87 <th className={th}>Rate limit</th>88 <th className={th}>Priority</th>89 <th className={th}>Backfill</th>90 <th className={th}>Actions</th>91 </tr>92 </thead>93 <tbody>94 {rows.length === 0 ? (95 <tr>96 <td className={td} colSpan={13}>97 <span className="text-subtle">No connector matches. Add meta.json files under connectors/, run pnpm registry && pnpm db:seed.</span>98 </td>99 </tr>100 ) : null}101 {rows.map((r) => {102 const meta = (r.meta ?? {}) as Row;103 const health = (r.health ?? {}) as Row;104 const anomalies = Array.isArray(r.last_anomalies) ? (r.last_anomalies as string[]) : [];105 const drift = ((health.schema_drift as string[]) ?? []).length > 0 || anomalies.some((a) => /schema_drift|selector|pagination_failure|result_count_collapse/.test(a));106 const httpErrors = Object.values((health.http_errors as Record<string, number>) ?? {}).reduce((a, b) => a + b, 0);107 const cats = (r.categories as string[]) ?? [];108 const id = str(r.id);109 const domain = str(meta.domain);110 const country = str(meta.country ?? (r.regions as string[])?.[0] ?? 'global');111 const refresh = Number(r.refresh_frequency_minutes);112 const refreshClass = str(meta.refreshClass) || (refresh <= 5 ? 'hot' : refresh <= 60 ? 'active' : refresh <= 1440 ? 'normal' : 'archive');113 const missing = (health.missing_requirements as string[]) ?? [];114 return (115 <tr key={id} className="align-top">116 <td className={td}>117 <div className="flex items-center gap-2">118 <SourceLogo domain={domain} name={str(r.display_name)} logoUrl={str(meta.logoUrl) || null} />119 <div>120 <Link href={`/admin/connectors/${id}`} className="font-medium hover:underline">{str(r.display_name)}</Link>121 <div className="text-[11px] text-subtle">122 {domain ? (123 <a href={`https://${domain}`} target="_blank" rel="noreferrer noopener" className="hover:underline">{domain}</a>124 ) : (125 id126 )}127 {' · '}128 {id}129 </div>130 </div>131 </div>132 </td>133 <td className={td}>134 <div className="flex max-w-[220px] flex-wrap gap-1">135 {cats.slice(0, 3).map((c) => (136 <Chip key={c}>{c}</Chip>137 ))}138 {cats.length > 3 ? <Chip>+{cats.length - 3}</Chip> : null}139 <Chip tone="index">{country}</Chip>140 </div>141 </td>142 <td className={td}>143 <HealthLabel status={str(r.health_status) || 'unknown'} />144 <div className="text-[10px] text-subtle">145 {health.success_rate_24h != null ? <>success <Pct value={health.success_rate_24h} /></> : 'no runs 24h'}146 {r.health_at ? ` · ${fmtTs(r.health_at).slice(5, 16)}` : ''}147 </div>148 {drift ? <div className="text-[10px] text-alert">schema drift?</div> : null}149 {missing.length ? <div className="text-[10px] text-alert">needs {missing.join(', ')}</div> : null}150 </td>151 <td className={td}>152 <StatusPill status={str(r.status)} />153 {r.last_run_status ? <div className="text-[10px] text-subtle">run: {str(r.last_run_status)}</div> : null}154 </td>155 <td className={td}>156 <div className="text-xs">{str(meta.acquisitionMethod) || (r.engine_priority as string[]).join(' → ')}</div>157 <div className="text-[10px] text-subtle">{(r.engine_priority as string[]).join('→')} · {refreshClass} · every {n(refresh)} min</div>158 </td>159 <td className={td}>160 {fmtTs(r.last_started_at)}161 <div className="text-[10px] text-subtle">ok: {fmtTs(r.last_success_at).slice(0, 16)}</div>162 <div className="text-[10px] text-subtle">fresh: {health.data_freshness ? fmtTs(health.data_freshness).slice(0, 10) : '—'}</div>163 </td>164 <td className={tdNum}>165 {n(r.records_24h)}166 <div className="text-[10px] text-subtle">{n(r.normalized_24h)} norm. · {n(r.duplicates_24h)} dup.</div>167 <div className="text-[10px] text-subtle">{n(r.pages_per_min, 1)} pages/min</div>168 </td>169 <td className={`${tdNum} ${r.last_error || httpErrors ? 'text-loss' : ''}`} title={str(r.last_error)}>170 {httpErrors || (r.last_error ? 1 : 0)}171 {anomalies.length ? <div className="text-[10px] text-alert">{anomalies.length} anomal.</div> : null}172 {Number(health.challenges_24h) > 0 ? <div className="text-[10px] text-alert">{n(health.challenges_24h)} challenges</div> : null}173 </td>174 <td className={tdNum}>175 {n(r.fc_credits_24h)} · {n(r.sf_credits_24h)}176 <div className="text-[10px] text-subtle">177 FC <Pct value={health.firecrawl_success_rate} /> · SF <Pct value={health.scrapfly_fallback_rate} />178 </div>179 </td>180 <td className={td}>181 <div className="text-xs">{health.latency_ms_avg != null ? `${n(health.latency_ms_avg)} ms` : '—'}</div>182 <div className="text-[10px] text-subtle">{Number(health.rate_limited_24h) > 0 ? `${n(health.rate_limited_24h)}× 429` : 'no 429'} · {n(health.requests_24h)} req</div>183 </td>184 <td className={td}>185 <Chip tone={str(r.priority) === 'high' ? 'index' : 'neutral'}>{str(r.priority)}</Chip>186 <div className="text-[10px] text-subtle">trust {source_trust(meta)}</div>187 </td>188 <td className={td}>{r.backfill_id ? <Progress percent={r.backfill_percent as number | null} status={str(r.backfill_status)} /> : <span className="text-[10px] text-subtle">{str(meta.historicalDepth) && str(meta.historicalDepth) !== 'none' ? `history: ${str(meta.historicalDepth)}` : '—'}</span>}</td>189 <td className={td}>190 <div className="flex max-w-[210px] flex-wrap gap-1">191 {(192 [193 ['probe', 'Test'],194 ['run', 'Run now'],195 ['backfill', 'Backfill'],196 ] as const197 ).map(([a, label]) => (198 <form key={a} action={connectorAction}>199 <input type="hidden" name="id" value={id} />200 <input type="hidden" name="action" value={a} />201 <ActionButton label={label} />202 </form>203 ))}204 <form action={connectorAction}>205 <input type="hidden" name="id" value={id} />206 <input type="hidden" name="action" value={r.status === 'active' ? 'pause' : 'resume'} />207 <ActionButton label={r.status === 'active' ? 'Pause' : 'Resume'} tone={r.status === 'active' ? 'danger' : 'primary'} />208 </form>209 <Link href={`/admin/connectors/${id}`} className="rounded-md border border-border px-2 py-0.5 text-[11px] font-medium hover:bg-inset">Logs</Link>210 </div>211 </td>212 </tr>213 );214 })}215 </tbody>216 </Table>217 </div>218 <p className="mt-3 text-[11px] text-subtle">219 Test = 25-record probe · Run now = incremental crawl · Backfill = resumable historical campaign (progress in the Backfill column) · Pause/Resume flips scheduling; Maintenance is available on the connector page. FC/SF = Firecrawl/Scrapfly credits spent in 24 h and success/fallback rates. Health labels: UP · DEGRADED · BROKEN · DISABLED · MAINTENANCE.220 </p>221 </AdminShell>222 );223}224225function source_trust(meta: Row): string {226 const t = Number(meta.trustScore);227 return Number.isFinite(t) ? `${Math.round(t * 100)}%` : '—';228}229