SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
33.2 KB · 728 lines tsx
Raw Blame History
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