spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1'use client';2import Link from 'next/link';3import { useCallback, useEffect, useState } from 'react';4import { Bars } from '@/components/charts/bars';5import { SensorTierBadge, StatusBadge } from '@/components/ui/badges';6import { LiveAgo } from '@/components/ui/live';7import { Note } from '@/components/ui/section';8import { SkeletonRows } from '@/components/ui/skeleton';9import { useAdminToken } from '@/lib/admin';10import { adminApi } from '@/lib/client-api';11import { fmt1, fmtBytes, fmtDateTime, fmtInt, fmtPct, fmtScore, fmtUsd, pathOf } from '@/lib/format';12import { routes } from '@/lib/site';13import type { AdminCompany, AdminConnector, AdminCosts, AdminFailure, AdminLlmJob, AdminOverview, AdminQuality, AdminQueueItem, AdminReview, AdminSensor, Page } from '@/lib/types';14import { AdminError, AdminShell, AdminTable, KpiRow } from './admin-shell';1516/** Generic loader: fetches with the admin token, exposes refresh; children render data. */17function useAdminData<T>(loader: (api: ReturnType<typeof adminApi>, signal: AbortSignal) => Promise<T>, deps: unknown[] = []) {18 const token = useAdminToken();19 const [data, setData] = useState<T | null>(null);20 const [error, setError] = useState<string | null>(null);21 const [loading, setLoading] = useState(false);22 const [tick, setTick] = useState(0);23 const refresh = useCallback(() => setTick((t) => t + 1), []);24 useEffect(() => {25 if (!token) return;26 const ctrl = new AbortController();27 setLoading(true);28 loader(adminApi(token), ctrl.signal)29 .then((d) => {30 setData(d);31 setError(null);32 })33 .catch((e: Error) => {34 if (e.name !== 'AbortError') setError(e.message);35 })36 .finally(() => setLoading(false));37 return () => ctrl.abort();38 // eslint-disable-next-line react-hooks/exhaustive-deps39 }, [token, tick, ...deps]);40 return { data, error, loading, refresh, token };41}4243const entries = (o: Record<string, number> | undefined) => Object.entries(o ?? {}).sort((a, b) => b[1] - a[1]);4445/* -------------------------------------------------------------------------------------------------------------- overview */46export function OverviewModule() {47 const { data, error, loading, refresh } = useAdminData<AdminOverview>((api, s) => api.overview(s));48 return (49 <AdminShell title="Overview" onRefresh={refresh} refreshing={loading}>50 <AdminError error={error} />51 {!data ? (52 <SkeletonRows />53 ) : (54 <div className="space-y-6">55 <KpiRow56 items={[57 { label: 'Queue pending', value: fmtInt(data.queue.pending), hint: `${fmtInt(data.queue.running)} running · oldest ${fmtInt(data.queue.oldest_pending_s)} s`, tone: data.queue.oldest_pending_s && data.queue.oldest_pending_s > 300 ? 'warning' : undefined },58 { label: 'Dead jobs', value: fmtInt(data.queue.dead), tone: data.queue.dead ? 'danger' : undefined },59 { label: 'Fetch / h', value: fmtInt(data.fetch_rate_1h) },60 { label: 'Changes / h', value: fmtInt(data.change_rate_1h), hint: `${fmtInt(data.meaningful_rate_1h)} meaningful` },61 { label: 'LLM pending', value: fmtInt(data.llm.pending), hint: `${fmtInt(data.llm.done_today)} done · ${fmtInt(data.llm.failed_today)} failed today` },62 { label: 'Cost today', value: fmtUsd(data.cost_today.fetch + data.cost_today.browser + data.cost_today.llm), hint: `fetch ${fmtUsd(data.cost_today.fetch)} · browser ${fmtUsd(data.cost_today.browser)} · llm ${fmtUsd(data.cost_today.llm)}` },63 ]}64 />65 <div className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">66 <div>67 <p className="eyebrow mb-2">Sensors by status</p>68 <Bars dense rows={entries(data.sensors_by_status).map(([k, v]) => ({ key: k, label: k, value: v, color: k === 'failing' ? 'var(--danger)' : k === 'active' ? 'var(--positive)' : undefined }))} />69 </div>70 <div>71 <p className="eyebrow mb-2">Sensors by tier</p>72 <Bars dense rows={entries(data.sensors_by_tier).map(([k, v]) => ({ key: k, label: `Tier ${k}`, value: v }))} />73 </div>74 <div>75 <p className="eyebrow mb-2">Failures 24 h by class</p>76 <Bars dense rows={entries(data.failures_24h_by_class).map(([k, v]) => ({ key: k, label: k, value: v, color: 'var(--danger)', href: `/admin/failures?class=${k}` }))} />77 </div>78 <div>79 <p className="eyebrow mb-2">Companies by status</p>80 <Bars dense rows={entries(data.companies_by_status).map(([k, v]) => ({ key: k, label: k, value: v }))} />81 </div>82 </div>83 <div className="grid gap-6 md:grid-cols-2">84 <div>85 <p className="eyebrow mb-2">Workers</p>86 <AdminTable87 head={88 <tr>89 <th>Name</th>90 <th>Last seen</th>91 <th className="num">In flight</th>92 </tr>93 }94 >95 {data.workers.map((w) => (96 <tr key={w.name}>97 <td className="mono primary">{w.name}</td>98 <td className="text-xs">99 <LiveAgo at={w.last_seen_at} tick={5000} />100 </td>101 <td className="num tnum">{w.inflight}</td>102 </tr>103 ))}104 </AdminTable>105 </div>106 <div>107 <p className="eyebrow mb-2">Storage</p>108 <p className="tnum text-sm">109 {fmtInt(data.storage.objects)} objects · {fmtBytes(data.storage.bytes)} · LLM budget left {data.llm.budget_left === null ? '—' : fmtPct(data.llm.budget_left, 0)}110 </p>111 </div>112 </div>113 </div>114 )}115 </AdminShell>116 );117}118119/* -------------------------------------------------------------------------------------------------------------- connectors */120export function ConnectorsModule() {121 const { data, error, loading, refresh } = useAdminData<{ items: AdminConnector[] }>((api, s) => api.connectors(s));122 return (123 <AdminShell title="Connector control center" onRefresh={refresh} refreshing={loading}>124 <AdminError error={error} />125 {!data ? (126 <SkeletonRows />127 ) : (128 <AdminTable129 head={130 <tr>131 <th>Connector</th>132 <th>Version</th>133 <th>Category</th>134 <th>Enabled</th>135 <th className="num">Active</th>136 <th className="num">Failing</th>137 <th className="num">Success 24 h</th>138 <th className="num">Latency</th>139 <th className="num">Change rate</th>140 <th className="num">Errors 24 h</th>141 <th>Last run</th>142 </tr>143 }144 >145 {data.items.map((c) => (146 <tr key={c.id}>147 <td className="primary">148 <Link href={`/admin/sensors?connector=${c.id}`} className="row-link">149 {c.name}150 </Link>151 <span className="mono block text-[11px] font-normal text-ink-3">{c.id}</span>152 </td>153 <td className="mono text-xs">{c.version}</td>154 <td className="text-ink-2">{c.category}</td>155 <td>{c.enabled ? <StatusBadge status="active" /> : <StatusBadge status="paused" />}</td>156 <td className="num tnum">{fmtInt(c.sensors_active)}</td>157 <td className={`num tnum ${c.sensors_failing ? 'text-danger' : ''}`}>{fmtInt(c.sensors_failing)}</td>158 <td className="num tnum">{fmtPct(c.success_rate_24h, 1)}</td>159 <td className="num tnum">{c.avg_latency_ms === null ? '—' : `${fmtInt(c.avg_latency_ms)} ms`}</td>160 <td className="num tnum">{fmtPct(c.change_rate_24h, 1)}</td>161 <td className="num tnum">{fmtInt(c.errors_24h)}</td>162 <td className="text-xs text-ink-3">163 <LiveAgo at={c.last_run_at} tick={10000} />164 </td>165 </tr>166 ))}167 </AdminTable>168 )}169 </AdminShell>170 );171}172173/* -------------------------------------------------------------------------------------------------------------- sensors */174const SENSOR_FILTERS = ['', 'healthy', 'failing', 'stale', 'blocked', 'redirected', 'low_quality', 'high_activity'];175const ACTIONS = ['pause', 'resume', 'retry', 'rediscover', 'retire', 'run_now'];176export function SensorsModule({ initial }: { initial: Record<string, string> }) {177 const [filter, setFilter] = useState(initial.filter ?? '');178 const [domain, setDomain] = useState(initial.domain ?? '');179 const [connector, setConnector] = useState(initial.connector ?? '');180 const [page, setPage] = useState(1);181 const [msg, setMsg] = useState<string | null>(null);182 const { data, error, loading, refresh, token } = useAdminData<Page<AdminSensor>>((api, s) => api.sensors({ filter: filter || undefined, domain: domain || undefined, connector: connector || undefined, page, per_page: 50 }, s), [filter, domain, connector, page]);183 const act = async (id: string, action: string) => {184 if (!token) return;185 const body: Record<string, unknown> = {};186 if (action === 'set_interval') {187 const v = prompt('New interval in seconds');188 if (!v) return;189 body.interval_s = Number(v);190 }191 try {192 await adminApi(token).sensorAction(id, action, body);193 setMsg(`${action} → ${id}`);194 refresh();195 } catch (e) {196 setMsg(`failed: ${(e as Error).message}`);197 }198 };199 return (200 <AdminShell title="Sensor control center" onRefresh={refresh} refreshing={loading}>201 <AdminError error={error} />202 <div className="mb-3 flex flex-wrap items-center gap-2">203 <div className="no-scrollbar -mx-4 flex gap-1 overflow-x-auto px-4 md:mx-0 md:px-0">204 {SENSOR_FILTERS.map((f) => (205 <button key={f || 'all'} type="button" onClick={() => { setFilter(f); setPage(1); }} className="chip-btn" data-on={filter === f}>206 {f ? f.replace('_', ' ') : 'all'}207 </button>208 ))}209 </div>210 <input value={domain} onChange={(e) => { setDomain(e.target.value); setPage(1); }} placeholder="domain contains…" className="field h-9 w-44 text-xs" aria-label="Domain filter" />211 <input value={connector} onChange={(e) => { setConnector(e.target.value); setPage(1); }} placeholder="connector id" className="field h-9 w-40 text-xs" aria-label="Connector filter" />212 {msg && <span className="mono text-xs text-ink-3">{msg}</span>}213 </div>214 {!data ? (215 <SkeletonRows rows={10} />216 ) : (217 <>218 <AdminTable219 head={220 <tr>221 <th>Company</th>222 <th>Surface</th>223 <th>URL</th>224 <th>Status</th>225 <th>Tier</th>226 <th className="num">Q</th>227 <th className="num">Fails</th>228 <th>Last success</th>229 <th className="num">Interval</th>230 <th>Actions</th>231 </tr>232 }233 >234 {data.items.map((s) => (235 <tr key={s.id}>236 <td className="primary">237 <Link href={routes.company(s.company.slug)} className="row-link">238 {s.company.display_name}239 </Link>240 </td>241 <td>242 <Link href={routes.sensor(s.id)} className="row-link">243 {s.surface}244 </Link>245 </td>246 <td className="mono text-xs text-ink-2">{pathOf(s.url)}</td>247 <td>248 <StatusBadge status={s.status} />249 {s.last_failure_class && <span className="mono ml-1 text-[10px] text-danger">{s.last_failure_class}</span>}250 </td>251 <td>252 <SensorTierBadge tier={s.tier} />253 </td>254 <td className="num tnum">{fmtScore(s.quality_score)}</td>255 <td className={`num tnum ${s.consecutive_failures ? 'text-danger' : ''}`}>{s.consecutive_failures}</td>256 <td className="text-xs text-ink-3">257 <LiveAgo at={s.last_success_at} tick={30000} />258 </td>259 <td className="num tnum text-xs">{fmtInt(s.current_interval_s)} s</td>260 <td>261 <span className="flex flex-wrap gap-1">262 {ACTIONS.filter((a) => (s.status === 'paused' ? a !== 'pause' : a !== 'resume')).map((a) => (263 <button key={a} type="button" onClick={() => act(s.id, a)} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:border-rule-strong hover:text-ink">264 {a.replace('_', ' ')}265 </button>266 ))}267 <button type="button" onClick={() => act(s.id, 'set_interval')} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:text-ink">268 interval269 </button>270 </span>271 </td>272 </tr>273 ))}274 </AdminTable>275 <div className="mt-2 flex items-center justify-between text-xs text-ink-3">276 <span className="tnum">277 page {data.page} / {data.pages} · {fmtInt(data.total)} sensors278 </span>279 <span className="flex gap-1">280 <button type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} className="btn btn-sm">281 ‹ Prev282 </button>283 <button type="button" onClick={() => setPage((p) => Math.min(data.pages, p + 1))} disabled={page >= data.pages} className="btn btn-sm">284 Next ›285 </button>286 </span>287 </div>288 </>289 )}290 </AdminShell>291 );292}293294/* -------------------------------------------------------------------------------------------------------------- companies */295export function CompaniesModule() {296 const [status, setStatus] = useState('');297 const [page, setPage] = useState(1);298 const [form, setForm] = useState({ website: '', display_name: '', country: '', industries: '' });299 const [msg, setMsg] = useState<string | null>(null);300 const { data, error, loading, refresh, token } = useAdminData<Page<AdminCompany>>((api, s) => api.companies({ onboarding_status: status || undefined, page, per_page: 50 }, s), [status, page]);301 const submit = async (e: React.FormEvent) => {302 e.preventDefault();303 if (!token || !form.website) return;304 try {305 await adminApi(token).createCompany({ website: form.website, display_name: form.display_name || undefined, country: form.country || undefined, industries: form.industries ? form.industries.split(',').map((s) => s.trim()).filter(Boolean) : undefined });306 setMsg(`queued discovery for ${form.website}`);307 setForm({ website: '', display_name: '', country: '', industries: '' });308 refresh();309 } catch (err) {310 setMsg(`failed: ${(err as Error).message}`);311 }312 };313 return (314 <AdminShell title="Companies" onRefresh={refresh} refreshing={loading}>315 <AdminError error={error} />316 <form onSubmit={submit} className="mb-4 grid gap-2 border border-rule p-3 sm:grid-cols-[2fr_1.5fr_0.7fr_1.5fr_auto]">317 <input value={form.website} onChange={(e) => setForm({ ...form, website: e.target.value })} placeholder="https://example.com (required)" className="field text-sm" aria-label="Website" required />318 <input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} placeholder="Display name" className="field text-sm" aria-label="Display name" />319 <input value={form.country} onChange={(e) => setForm({ ...form, country: e.target.value.toUpperCase() })} placeholder="CC" maxLength={2} className="field text-sm" aria-label="Country code" />320 <input value={form.industries} onChange={(e) => setForm({ ...form, industries: e.target.value })} placeholder="industries, comma-separated" className="field text-sm" aria-label="Industries" />321 <button type="submit" className="btn btn-primary">322 Add company323 </button>324 </form>325 {msg && <p className="mono mb-2 text-xs text-ink-3">{msg}</p>}326 <div className="mb-2 flex gap-1">327 {['', 'pending', 'discovering', 'active', 'failed'].map((s) => (328 <button key={s || 'all'} type="button" onClick={() => { setStatus(s); setPage(1); }} className="chip-btn" data-on={status === s}>329 {s || 'all'}330 </button>331 ))}332 </div>333 {!data ? (334 <SkeletonRows rows={10} />335 ) : (336 <AdminTable337 head={338 <tr>339 <th>Company</th>340 <th>Domain</th>341 <th>Country</th>342 <th>Status</th>343 <th>Onboarding</th>344 <th className="num">Tier</th>345 <th className="num">Sensors</th>346 <th className="num">Events</th>347 <th>Last observed</th>348 <th />349 </tr>350 }351 >352 {data.items.map((c) => (353 <tr key={c.id}>354 <td className="primary">355 <Link href={routes.company(c.slug)} className="row-link">356 {c.display_name}357 </Link>358 </td>359 <td className="mono text-xs">{c.canonical_domain}</td>360 <td className="mono text-xs">{c.country ?? '—'}</td>361 <td>362 <StatusBadge status={c.status} />363 </td>364 <td>365 <StatusBadge status={c.onboarding_status} />366 </td>367 <td className="num tnum">{c.tier}</td>368 <td className="num tnum">{fmtInt(c.counts.sensors)}</td>369 <td className="num tnum">{fmtInt(c.counts.events)}</td>370 <td className="text-xs text-ink-3">371 <LiveAgo at={c.last_observed_at} tick={30000} />372 </td>373 <td>374 <button type="button" onClick={() => token && adminApi(token).rediscover(c.slug).then(() => setMsg(`rediscover queued for ${c.slug}`)).catch((e: Error) => setMsg(e.message))} className="border border-rule px-1.5 py-0.5 text-[11px] text-ink-2 hover:text-ink">375 rediscover376 </button>377 </td>378 </tr>379 ))}380 </AdminTable>381 )}382 </AdminShell>383 );384}385386/* -------------------------------------------------------------------------------------------------------------- failures */387export function FailuresModule({ initialClass }: { initialClass?: string }) {388 const [cls, setCls] = useState(initialClass ?? '');389 const { data, error, loading, refresh } = useAdminData<Page<AdminFailure>>((api, s) => api.failures({ class: cls || undefined, per_page: 100 }, s), [cls]);390 return (391 <AdminShell title="Failures" onRefresh={refresh} refreshing={loading}>392 <AdminError error={error} />393 <div className="mb-2 flex flex-wrap items-center gap-2">394 <input value={cls} onChange={(e) => setCls(e.target.value.toUpperCase())} placeholder="class (TIMEOUT, HTTP_4XX, BOT_CHALLENGE…)" className="field h-9 w-72 text-xs" aria-label="Failure class" />395 </div>396 {!data ? (397 <SkeletonRows rows={10} />398 ) : (399 <AdminTable400 head={401 <tr>402 <th>When</th>403 <th>Class</th>404 <th className="num">HTTP</th>405 <th>Company</th>406 <th>Domain</th>407 <th>Message</th>408 <th>Retry</th>409 </tr>410 }411 >412 {data.items.map((f) => (413 <tr key={f.id}>414 <td className="tnum text-xs">{fmtDateTime(f.occurred_at)}</td>415 <td className="mono text-xs text-danger">{f.failure_class}</td>416 <td className="num tnum">{f.status_code || '—'}</td>417 <td>{f.company ? <Link href={routes.company(f.company.slug)} className="row-link">{f.company.display_name}</Link> : '—'}</td>418 <td className="mono text-xs">{f.domain ?? '—'}</td>419 <td className="wrap text-xs text-ink-2">{f.message ?? '—'}</td>420 <td className="text-xs text-ink-3">{f.retry_at ? <LiveAgo at={f.retry_at} tick={30000} /> : '—'}</td>421 </tr>422 ))}423 </AdminTable>424 )}425 </AdminShell>426 );427}428429/* -------------------------------------------------------------------------------------------------------------- queue */430export function QueueModule() {431 const [kind, setKind] = useState('');432 const [status, setStatus] = useState('');433 const [msg, setMsg] = useState<string | null>(null);434 const { data, error, loading, refresh, token } = useAdminData<{ items: AdminQueueItem[]; counts?: Record<string, number> } | Page<AdminQueueItem>>((api, s) => api.queue({ kind: kind || undefined, status: status || undefined }, s), [kind, status]);435 const items = data?.items ?? [];436 const counts = data && 'counts' in data ? data.counts : undefined;437 return (438 <AdminShell title="Crawl queue" onRefresh={refresh} refreshing={loading}>439 <AdminError error={error} />440 <div className="mb-3 flex flex-wrap items-center gap-2">441 <select value={kind} onChange={(e) => setKind(e.target.value)} className="field h-9 text-xs" aria-label="Kind">442 <option value="">all kinds</option>443 {['fetch', 'discover', 'enrich', 'metrics', 'daily'].map((k) => (444 <option key={k}>{k}</option>445 ))}446 </select>447 <select value={status} onChange={(e) => setStatus(e.target.value)} className="field h-9 text-xs" aria-label="Status">448 <option value="">all statuses</option>449 {['pending', 'running', 'done', 'dead'].map((k) => (450 <option key={k}>{k}</option>451 ))}452 </select>453 {counts && <span className="mono text-xs text-ink-3">{Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(' · ')}</span>}454 <button type="button" onClick={() => token && adminApi(token).requeueDead().then((r) => { setMsg(JSON.stringify(r)); refresh(); }).catch((e: Error) => setMsg(e.message))} className="btn btn-sm ml-auto">455 Requeue dead456 </button>457 {msg && <span className="mono text-xs text-ink-3">{msg}</span>}458 </div>459 {!data ? (460 <SkeletonRows rows={10} />461 ) : (462 <AdminTable463 head={464 <tr>465 <th>Id</th>466 <th>Kind</th>467 <th>Status</th>468 <th className="num">Prio</th>469 <th className="num">Attempts</th>470 <th>Scheduled</th>471 <th>Worker</th>472 <th>Ref</th>473 <th>Error</th>474 </tr>475 }476 >477 {items.map((j) => (478 <tr key={j.id}>479 <td className="mono text-[11px] text-ink-3">{j.id}</td>480 <td>{j.kind}</td>481 <td>482 <StatusBadge status={j.status} />483 </td>484 <td className="num tnum">{j.priority}</td>485 <td className="num tnum">{j.attempts}</td>486 <td className="text-xs text-ink-3">487 <LiveAgo at={j.scheduled_at} tick={10000} />488 </td>489 <td className="mono text-xs">{j.worker ?? '—'}</td>490 <td className="mono text-[11px] text-ink-3">{j.ref ?? '—'}</td>491 <td className="text-xs text-danger">{j.error ?? ''}</td>492 </tr>493 ))}494 </AdminTable>495 )}496 </AdminShell>497 );498}499500/* -------------------------------------------------------------------------------------------------------------- llm */501export function LlmModule() {502 const [status, setStatus] = useState('');503 const { data, error, loading, refresh } = useAdminData<Page<AdminLlmJob>>((api, s) => api.llm({ status: status || undefined, per_page: 100 }, s), [status]);504 return (505 <AdminShell title="LLM jobs" onRefresh={refresh} refreshing={loading}>506 <AdminError error={error} />507 <div className="mb-2 flex gap-1">508 {['', 'pending', 'done', 'failed'].map((s) => (509 <button key={s || 'all'} type="button" onClick={() => setStatus(s)} className="chip-btn" data-on={status === s}>510 {s || 'all'}511 </button>512 ))}513 </div>514 {!data ? (515 <SkeletonRows rows={10} />516 ) : (517 <AdminTable518 head={519 <tr>520 <th>Created</th>521 <th>Kind</th>522 <th>Status</th>523 <th>Model</th>524 <th>Prompt</th>525 <th className="num">Tokens in / out</th>526 <th className="num">Cost</th>527 <th>Change</th>528 <th>Event</th>529 <th>Error</th>530 </tr>531 }532 >533 {data.items.map((j) => (534 <tr key={j.id}>535 <td className="tnum text-xs">{fmtDateTime(j.created_at)}</td>536 <td>{j.kind}</td>537 <td>538 <StatusBadge status={j.status} />539 </td>540 <td className="mono text-xs">{j.model ?? '—'}</td>541 <td className="mono text-xs text-ink-3">{j.prompt_version ?? '—'}</td>542 <td className="num tnum text-xs">543 {fmtInt(j.tokens_in)} / {fmtInt(j.tokens_out)}544 </td>545 <td className="num tnum text-xs">{j.cost_estimate === null ? '—' : `$${j.cost_estimate.toFixed(3)}`}</td>546 <td>{j.change_id ? <Link href={routes.change(j.change_id)} className="link mono text-[11px]">{j.change_id.slice(0, 12)}…</Link> : '—'}</td>547 <td>{j.event_id ? <Link href={routes.event(j.event_id)} className="link mono text-[11px]">{j.event_id.slice(0, 12)}…</Link> : '—'}</td>548 <td className="text-xs text-danger">{j.error ?? ''}</td>549 </tr>550 ))}551 </AdminTable>552 )}553 </AdminShell>554 );555}556557/* -------------------------------------------------------------------------------------------------------------- reviews */558export function ReviewsModule() {559 const [status, setStatus] = useState('open');560 const [msg, setMsg] = useState<string | null>(null);561 const { data, error, loading, refresh, token } = useAdminData<{ items: AdminReview[] } | Page<AdminReview>>((api, s) => api.reviews({ status: status || undefined }, s), [status]);562 const resolve = async (id: string, resolution: 'accepted' | 'rejected') => {563 if (!token) return;564 try {565 await adminApi(token).resolveReview(id, resolution);566 setMsg(`${resolution}: ${id}`);567 refresh();568 } catch (e) {569 setMsg((e as Error).message);570 }571 };572 const retract = async (eventId: string) => {573 if (!token) return;574 const reason = prompt('Retraction reason (kept in the audit history)');575 if (!reason) return;576 try {577 await adminApi(token).retractEvent(eventId, reason);578 setMsg(`retracted ${eventId}`);579 } catch (e) {580 setMsg((e as Error).message);581 }582 };583 return (584 <AdminShell title="Review queue" onRefresh={refresh} refreshing={loading}>585 <AdminError error={error} />586 <div className="mb-2 flex items-center gap-1">587 {['open', 'resolved', ''].map((s) => (588 <button key={s || 'all'} type="button" onClick={() => setStatus(s)} className="chip-btn" data-on={status === s}>589 {s || 'all'}590 </button>591 ))}592 {msg && <span className="mono ml-2 text-xs text-ink-3">{msg}</span>}593 </div>594 {!data ? (595 <SkeletonRows rows={8} />596 ) : (597 <ul className="divide-y divide-rule border-y border-rule">598 {data.items.map((r) => (599 <li key={r.id} className="py-3">600 <div className="flex flex-wrap items-center gap-2 text-sm">601 <span className="mono text-[11px] uppercase tracking-wider text-ink-3">{r.kind.replace(/_/g, ' ')}</span>602 <StatusBadge status={r.status} />603 {r.company && (604 <Link href={routes.company(r.company.slug)} className="font-medium text-ink hover:text-accent">605 {r.company.display_name}606 </Link>607 )}608 <span className="ml-auto text-xs text-ink-3">{fmtDateTime(r.created_at)}</span>609 </div>610 <p className="mt-1 text-[14px] text-ink">{r.subject}</p>611 {r.reason && <p className="text-xs text-ink-3">reason: {r.reason}</p>}612 {r.status === 'open' && (613 <div className="mt-2 flex flex-wrap gap-1.5">614 <button type="button" onClick={() => resolve(r.id, 'accepted')} className="btn btn-sm">615 Accept616 </button>617 <button type="button" onClick={() => resolve(r.id, 'rejected')} className="btn btn-sm">618 Reject619 </button>620 {r.ref_id?.startsWith('evt_') && (621 <>622 <Link href={routes.event(r.ref_id)} className="btn btn-sm">623 Open event624 </Link>625 <button type="button" onClick={() => retract(r.ref_id as string)} className="btn btn-sm text-danger">626 Retract event627 </button>628 </>629 )}630 </div>631 )}632 </li>633 ))}634 </ul>635 )}636 </AdminShell>637 );638}639640/* -------------------------------------------------------------------------------------------------------------- quality */641export function QualityModule() {642 const { data, error, loading, refresh } = useAdminData<AdminQuality>((api, s) => api.quality(s));643 return (644 <AdminShell title="Data quality" onRefresh={refresh} refreshing={loading}>645 <AdminError error={error} />646 {!data ? (647 <SkeletonRows />648 ) : (649 <div className="space-y-6">650 <KpiRow651 items={[652 { label: 'Companies active', value: fmtPct(data.coverage.companies_active_pct, 1) },653 { label: 'Sensors active', value: fmtPct(data.coverage.sensors_active_pct, 1) },654 { label: 'Checked in 24 h', value: fmtPct(data.freshness.sensors_checked_24h_pct, 1), hint: `${fmtInt(data.freshness.stale)} stale` },655 { label: 'Duplicate rate', value: fmtPct(data.duplicate_rate, 2), tone: (data.duplicate_rate ?? 0) > 3 ? 'warning' : undefined },656 { label: 'Event confidence', value: data.event_confidence_avg === null ? '—' : fmt1(data.event_confidence_avg * 100) + ' %' },657 { label: 'Failed sensors', value: fmtInt(data.failed_sensors), tone: data.failed_sensors ? 'danger' : undefined, hint: `${fmtInt(data.unknown_surfaces)} unknown surfaces` },658 ]}659 />660 <div className="max-w-md">661 <p className="eyebrow mb-2">Calibration (human-labelled sample)</p>662 <Bars rows={Object.entries(data.calibration).map(([k, v]) => ({ key: k, label: k, value: v, color: k === 'correct' ? 'var(--positive)' : k === 'misclassified' ? 'var(--danger)' : 'var(--warning)' }))} />663 </div>664 <Note>Calibration counts feed the evaluation set used before changing extractors, normalisers or prompts.</Note>665 </div>666 )}667 </AdminShell>668 );669}670671/* -------------------------------------------------------------------------------------------------------------- costs */672export function CostsModule() {673 const [days, setDays] = useState(30);674 const { data, error, loading, refresh } = useAdminData<AdminCosts>((api, s) => api.costs(days, s), [days]);675 const byDim: Record<string, { units: number; cost: number }> = {};676 for (const i of data?.items ?? []) {677 const k = `${i.dimension} · ${i.key}`;678 byDim[k] ??= { units: 0, cost: 0 };679 byDim[k].units += i.units;680 byDim[k].cost += i.cost_estimate;681 }682 return (683 <AdminShell title="Cost accounting" onRefresh={refresh} refreshing={loading}>684 <AdminError error={error} />685 <div className="mb-3 flex gap-1">686 {[7, 30, 90].map((d) => (687 <button key={d} type="button" onClick={() => setDays(d)} className="chip-btn" data-on={days === d}>688 {d} d689 </button>690 ))}691 </div>692 {!data ? (693 <SkeletonRows />694 ) : (695 <div className="space-y-6">696 <KpiRow697 items={[698 { label: 'Per 1 000 companies', value: fmtUsd(data.per_1000_companies) },699 { label: 'Per 1M observations', value: fmtUsd(data.per_million_observations) },700 { label: 'Per meaningful event', value: data.per_meaningful_event === null ? '—' : `$${data.per_meaningful_event.toFixed(4)}` },701 { label: `Total ${days} d`, value: fmtUsd(Object.values(byDim).reduce((a, b) => a + b.cost, 0)) },702 ]}703 />704 <AdminTable705 head={706 <tr>707 <th>Dimension</th>708 <th className="num">Units</th>709 <th className="num">Cost estimate</th>710 </tr>711 }712 >713 {Object.entries(byDim)714 .sort((a, b) => b[1].cost - a[1].cost)715 .map(([k, v]) => (716 <tr key={k}>717 <td className="primary">{k}</td>718 <td className="num tnum">{fmtInt(v.units)}</td>719 <td className="num tnum">{fmtUsd(v.cost)}</td>720 </tr>721 ))}722 </AdminTable>723 </div>724 )}725 </AdminShell>726 );727}728