SPB Git

spb/ai-atlas

Public

Web wave 2: compare, timeline, price index, leaderboards, hardware fit, history/as-of/diff, graph explorer, admin console, typed listings, OG images

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 08b1775 Browse files

77 changed files +6,501 −253

77 changed files
modified apps/web/package.json +2 −0
@@ -11,6 +11,7 @@
11 }, 11 },
12 "dependencies": { 12 "dependencies": {
13 "d3-array": "^3.2.4", 13 "d3-array": "^3.2.4",
14+ "d3-force": "^3.0.0",
14 "d3-scale": "^4.0.2", 15 "d3-scale": "^4.0.2",
15 "d3-shape": "^3.2.0", 16 "d3-shape": "^3.2.0",
16 "geist": "^1.5.1", 17 "geist": "^1.5.1",
@@ -23,6 +24,7 @@
23 "devDependencies": { 24 "devDependencies": {
24 "@tailwindcss/postcss": "^4", 25 "@tailwindcss/postcss": "^4",
25 "@types/d3-array": "^3.2.1", 26 "@types/d3-array": "^3.2.1",
27+ "@types/d3-force": "^3.0.10",
26 "@types/d3-scale": "^4.0.9", 28 "@types/d3-scale": "^4.0.9",
27 "@types/d3-shape": "^3.1.7", 29 "@types/d3-shape": "^3.1.7",
28 "@types/node": "^24.0.0", 30 "@types/node": "^24.0.0",
added apps/web/qa/screens-wave2.mjs +212 −0
@@ -0,0 +1,212 @@
1+/**
2+ * Wave-2 QA sweep: every route added in wave 2 (compare, timeline, prices, benchmark leaderboards, hardware fit, history/as-of,
3+ * diff, graph, admin, typed listings, manifest/OG) at 390 and 1440 px, dark and light — HTTP status, console errors, horizontal
4+ * overflow, screenshot — plus interaction flows: compare tray (add 2 models → matrix), hardware-fit form submit, admin login →
5+ * connectors → Run now, diff with two dates, graph renders nodes.
6+ *
7+ * Run: node qa/screens-wave2.mjs [BASE_URL] [API_URL] [ADMIN_TOKEN] (defaults http://localhost:8330, http://127.0.0.1:8331, dev-admin-token)
8+ * Screenshots → qa/screens/wave2/. Slugs are discovered live from the API — nothing is hardcoded.
9+ */
10+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
11+import { mkdirSync } from 'node:fs';
12+
13+const BASE = process.argv[2] ?? 'http://localhost:8330';
14+const API = process.argv[3] ?? 'http://127.0.0.1:8331';
15+const TOKEN = process.argv[4] ?? process.env.AIA_ADMIN_TOKEN ?? 'dev-admin-token';
16+const OUT = new URL('./screens/wave2/', import.meta.url).pathname;
17+mkdirSync(OUT, { recursive: true });
18+
19+const j = async (path, headers = {}) => (await fetch(`${API}/api/v1${path}`, { headers })).json();
20+const models = (await j('/models?limit=3&sort=quality')).items.map((m) => m.slug);
21+const bench = (await j('/benchmarks')).items.sort((a, b) => Number(b.result_count) - Number(a.result_count))[0]?.slug;
22+const benchModel = bench ? (await j(`/benchmarks/${bench}/results?limit=1`)).items[0]?.model?.slug : null;
23+const hardware = (await j('/hardware?limit=1&sort=memory')).items[0]?.slug;
24+const company = (await j('/companies?limit=1&sort=models')).items[0]?.slug;
25+const today = new Date().toISOString().slice(0, 10);
26+const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
27+const [m1, m2, m3] = models;
28+console.log(`discovered models=${models.join(',')} bench=${bench} benchModel=${benchModel} hardware=${hardware} company=${company}`);
29+
30+const PAGES = [
31+ '/compare',
32+ `/compare?ids=${m1},${m2}`,
33+ `/compare?ids=${m1},${m2},${m3}`,
34+ '/timeline',
35+ `/timeline?year=${today.slice(0, 4)}&category=price`,
36+ `/timeline?entity=${m1}`,
37+ '/prices',
38+ '/prices?sort=output&days=90&scale=log',
39+ '/benchmarks',
40+ `/benchmarks/${bench}`,
41+ `/benchmarks/${bench}?model=${benchModel}`,
42+ '/benchmarks/does-not-exist',
43+ '/hardware',
44+ '/hardware/fit',
45+ '/hardware/fit?memory_gb=32&quant=4bit&context=8192',
46+ `/hardware/${hardware}`,
47+ `/models/${m1}`,
48+ `/models/${m1}?tab=history`,
49+ `/models/${m1}?tab=history&asof=${today}`,
50+ `/models/${m1}?tab=history&asof=2020-01-01`,
51+ `/companies/${company}?tab=history`,
52+ '/diff',
53+ `/diff?a=${weekAgo}&b=${today}&scope=models`,
54+ `/graph/${m1}`,
55+ `/graph/${m1}?depth=2`,
56+ '/graph/does-not-exist',
57+ '/papers',
58+ '/frameworks',
59+ '/datasets',
60+ '/tools',
61+ '/developers',
62+ '/admin',
63+ '/admin/connectors',
64+ '/does-not-exist-wave2',
65+];
66+const LIGHT_SUBSET = new Set(['/compare', `/compare?ids=${m1},${m2}`, '/timeline', '/prices', `/benchmarks/${bench}`, '/hardware/fit?memory_gb=32&quant=4bit&context=8192', `/models/${m1}?tab=history&asof=${today}`, `/diff?a=${weekAgo}&b=${today}&scope=models`, `/graph/${m1}`, '/papers', '/frameworks', '/admin', '/developers']);
67+const WIDTHS = [390, 1440];
68+const THEMES = ['dark', 'light'];
69+const IGNORE = /favicon|Failed to load resource: the server responded with a status of 404|Failed to load resource: the server responded with a status of 401|Failed to load resource: the server responded with a status of 403/;
70+
71+const browser = await chromium.launch();
72+let failures = 0;
73+const slug = (p) => p.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home';
74+
75+function watch(page) {
76+ const errors = [];
77+ const onErr = (e) => errors.push(String(e));
78+ const onCon = (m) => {
79+ if (m.type() === 'error') errors.push(m.text());
80+ };
81+ page.on('pageerror', onErr);
82+ page.on('console', onCon);
83+ return { errors, off: () => (page.off('pageerror', onErr), page.off('console', onCon)) };
84+}
85+const clean = (errors, expected) => errors.filter((e) => !IGNORE.test(e)).filter((e) => !(expected === 404 && /Encountered a script tag while rendering React component/.test(e)));
86+
87+for (const theme of THEMES) {
88+ for (const width of WIDTHS) {
89+ const mobile = width < 768;
90+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: theme });
91+ await ctx.addInitScript((t) => localStorage.setItem('aia-theme', t), theme);
92+ const page = await ctx.newPage();
93+ for (const path of PAGES) {
94+ if (theme === 'light' && !LIGHT_SUBSET.has(path)) continue;
95+ const w = watch(page);
96+ const t0 = Date.now();
97+ const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
98+ await page.waitForTimeout(700);
99+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
100+ const applied = await page.evaluate(() => document.documentElement.getAttribute('data-theme')).catch(() => null);
101+ const expected = /does-not-exist/.test(path) ? 404 : 200;
102+ const status = res.status();
103+ const filtered = clean(w.errors, expected);
104+ const ok = status === expected && overflow <= 0 && filtered.length === 0 && applied === theme;
105+ if (!ok) failures++;
106+ console.log(`${ok ? 'OK ' : 'FAIL'} ${theme.padEnd(5)} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} theme=${applied} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
107+ await page.screenshot({ path: `${OUT}${theme}-${width}-${slug(path)}.png`, fullPage: false }).catch(() => undefined);
108+ w.off();
109+ }
110+ await ctx.close();
111+ }
112+}
113+
114+/* ------------------------------------------------------------------------------------------------------------ flows */
115+async function flow(name, width, fn) {
116+ const mobile = width < 768;
117+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' });
118+ await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark'));
119+ const page = await ctx.newPage();
120+ const w = watch(page);
121+ try {
122+ await fn(page);
123+ const filtered = clean(w.errors, 200);
124+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
125+ const ok = filtered.length === 0 && overflow <= 0;
126+ if (!ok) failures++;
127+ console.log(`${ok ? 'OK ' : 'FAIL'} flow ${name} @${width} errors=${filtered.length} overflow=${overflow}${filtered.length ? ' :: ' + filtered[0].slice(0, 160) : ''}`);
128+ } catch (e) {
129+ failures++;
130+ console.log(`FAIL flow ${name} @${width} :: ${String(e.message ?? e).slice(0, 200)}`);
131+ }
132+ await page.screenshot({ path: `${OUT}flow-${name}-${width}.png`, fullPage: false }).catch(() => undefined);
133+ w.off();
134+ await ctx.close();
135+}
136+
137+for (const width of WIDTHS) {
138+ // Compare tray: add two models from the /models listing, then open /compare and expect the matrix.
139+ await flow('compare-tray', width, async (page) => {
140+ await page.goto(`${BASE}/models?sort=quality`, { waitUntil: 'networkidle' });
141+ const buttons = page.locator('button[aria-pressed]');
142+ if ((await buttons.count()) < 2) throw new Error('no compare buttons on /models');
143+ await buttons.nth(0).click();
144+ await buttons.nth(1).click();
145+ await page.waitForTimeout(300);
146+ const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-compare') ?? '[]'));
147+ if (stored.length !== 2) throw new Error(`tray holds ${stored.length} items, expected 2`);
148+ await page.goto(`${BASE}/compare?ids=${stored.map((s) => s.slug).join(',')}`, { waitUntil: 'networkidle' });
149+ const cells = await page.locator('table tbody tr').count();
150+ if (cells < 3) throw new Error(`matrix has ${cells} rows`);
151+ if (!(await page.locator('table thead th').count()) >= 3) throw new Error('matrix header missing entity columns');
152+ });
153+
154+ // Hardware fit: choose presets and submit the form.
155+ await flow('hardware-fit', width, async (page) => {
156+ await page.goto(`${BASE}/hardware/fit`, { waitUntil: 'networkidle' });
157+ const form = page.locator('form[action="/hardware/fit"]').first();
158+ await form.locator('select[name="memory_gb"]').selectOption('64');
159+ await form.locator('select[name="quant"]').selectOption('8bit');
160+ await form.locator('select[name="context"]').selectOption('32768');
161+ await Promise.all([page.waitForURL(/memory_gb=64/), form.locator('button[type="submit"]').first().click()]);
162+ await page.waitForLoadState('networkidle');
163+ const text = await page.evaluate(() => document.body.innerText);
164+ if (!/estimated/i.test(text)) throw new Error('missing ESTIMATED label');
165+ if ((await page.locator('table tbody tr').count()) < 1) throw new Error('no fit rows');
166+ });
167+
168+ // Admin: login → connectors table → Run now.
169+ await flow('admin', width, async (page) => {
170+ await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
171+ const html = await page.content();
172+ if (html.includes(TOKEN)) throw new Error('admin token leaked into HTML before login');
173+ await page.locator('input[type="password"]').first().fill(TOKEN);
174+ await Promise.all([page.waitForURL(/\/admin\/(overview|connectors)/, { timeout: 30000 }), page.locator('form button[type="submit"]').first().click()]);
175+ await page.goto(`${BASE}/admin/connectors`, { waitUntil: 'networkidle' });
176+ if ((await page.content()).includes(TOKEN)) throw new Error('admin token leaked into connectors HTML');
177+ const rows = await page.locator('table tbody tr').count();
178+ if (rows < 1) throw new Error('connectors table empty');
179+ // Connectors with a run already pending render a disabled button — pick the first enabled one.
180+ const run = page.locator('button:not([disabled])', { hasText: /run now/i }).first();
181+ if (!(await run.count())) throw new Error('no enabled Run now button');
182+ await run.click();
183+ await page.waitForLoadState('networkidle');
184+ await page.waitForTimeout(500);
185+ const after = await page.evaluate(() => document.body.innerText);
186+ if (!/queued|run now|pending|scheduled|enqueued/i.test(after)) throw new Error('no run-now feedback');
187+ });
188+
189+ // Diff with two dates.
190+ await flow('diff', width, async (page) => {
191+ await page.goto(`${BASE}/diff`, { waitUntil: 'networkidle' });
192+ const form = page.locator('form[action="/diff"]').first();
193+ await form.locator('input[name="a"]').fill(weekAgo);
194+ await form.locator('input[name="b"]').fill(today);
195+ await Promise.all([page.waitForURL(/a=\d{4}-\d{2}-\d{2}/), form.locator('button[type="submit"]').first().click()]);
196+ await page.waitForLoadState('networkidle');
197+ const text = await page.evaluate(() => document.body.innerText);
198+ if (!/new entities/i.test(text)) throw new Error('diff sections missing');
199+ });
200+
201+ // Graph renders nodes.
202+ await flow('graph', width, async (page) => {
203+ await page.goto(`${BASE}/graph/${m1}`, { waitUntil: 'networkidle' });
204+ await page.waitForTimeout(1500);
205+ const n = await page.locator('svg [data-node], svg circle').count();
206+ if (n < 2) throw new Error(`graph has ${n} nodes`);
207+ });
208+}
209+
210+await browser.close();
211+console.log(failures ? `\n${failures} failure(s)` : '\nall wave-2 checks OK');
212+process.exit(failures ? 1 : 0);
modified apps/web/src/app/[type]/[slug]/page.tsx +5 −4
@@ -1,6 +1,6 @@
1 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
2 import { notFound, permanentRedirect } from 'next/navigation'; 2 import { notFound, permanentRedirect } from 'next/navigation';
3−import { EntityPage } from '@/components/entity/entity-page'; 3+import { EntityPage, type EntityPageParams } from '@/components/entity/entity-page';
4 import { entityMetadata, loadEntity } from '@/components/entity/load'; 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 import { api, safe } from '@/lib/api'; 5 import { api, safe } from '@/lib/api';
6 import { PATH_TYPES, routes } from '@/lib/site'; 6 import { PATH_TYPES, routes } from '@/lib/site';
@@ -9,7 +9,7 @@ import { PATH_TYPES, routes } from '@/lib/site';
9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories. 9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories.
10 * (/models and /companies have their own segment folders; static segments win over this dynamic one.) 10 * (/models and /companies have their own segment folders; static segments win over this dynamic one.)
11 */ 11 */
12−type Params = { params: Promise<{ type: string; slug: string }> }; 12+type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> };
13 13
14 export async function generateMetadata({ params }: Params): Promise<Metadata> { 14 export async function generateMetadata({ params }: Params): Promise<Metadata> {
15 const { type, slug } = await params; 15 const { type, slug } = await params;
@@ -17,12 +17,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
17 return entityMetadata(type, slug); 17 return entityMetadata(type, slug);
18 } 18 }
19 19
20−export default async function GenericEntityPage({ params }: Params) { 20+export default async function GenericEntityPage({ params, searchParams }: Params) {
21 const { type, slug } = await params; 21 const { type, slug } = await params;
22+ const sp = await searchParams;
22 if (!PATH_TYPES[type]) notFound(); 23 if (!PATH_TYPES[type]) notFound();
23 const d = await loadEntity(type, slug); 24 const d = await loadEntity(type, slug);
24 const canonical = routes.entity(d); 25 const canonical = routes.entity(d);
25 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical); 26 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical);
26 const related = await safe(api.entityRelated(d.slug, 10)); 27 const related = await safe(api.entityRelated(d.slug, 10));
27− return <EntityPage d={d} canonical={canonical} related={related?.items} />; 28+ return <EntityPage d={d} canonical={canonical} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
28 } 29 }
added apps/web/src/app/admin/cache/page.tsx +47 −0
@@ -0,0 +1,47 @@
1+import type { Metadata } from 'next';
2+import { ActionButton, AdminTitle, Notice } from '@/components/admin/ui';
3+import { flushCacheAction, recomputeQualityAction, recomputeStatsAction } from '@/lib/admin/actions';
4+import { requireAdmin } from '@/lib/admin/admin-api';
5+
6+export const metadata: Metadata = { title: 'Cache & maintenance', robots: { index: false, follow: false } };
7+export const dynamic = 'force-dynamic';
8+
9+export default async function AdminCachePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
10+ await requireAdmin();
11+ const sp = await searchParams;
12+ return (
13+ <>
14+ <AdminTitle title="Cache & maintenance" lede="Public API responses are cached in Redis (aia:api:*, 60–600 s). Flushing forces every public page to recompute on its next request." />
15+ <Notice notice={sp.notice} level={sp.level} />
16+ <ul className="divide-y divide-rule border-y border-rule">
17+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
18+ <div>
19+ <p className="text-sm font-medium text-ink">Flush API cache</p>
20+ <p className="text-xs text-ink-3">POST /admin/cache/flush — deletes every aia:api:* key. Safe; the next requests repopulate it.</p>
21+ </div>
22+ <form action={flushCacheAction}>
23+ <ActionButton tone="accent">Flush</ActionButton>
24+ </form>
25+ </li>
26+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
27+ <div>
28+ <p className="text-sm font-medium text-ink">Recompute stats</p>
29+ <p className="text-xs text-ink-3">POST /admin/stats/recompute — refreshes the daily stats history row for today.</p>
30+ </div>
31+ <form action={recomputeStatsAction}>
32+ <ActionButton>Recompute stats</ActionButton>
33+ </form>
34+ </li>
35+ <li className="flex flex-col gap-2 py-3 md:flex-row md:items-center md:justify-between">
36+ <div>
37+ <p className="text-sm font-medium text-ink">Recompute quality scores</p>
38+ <p className="text-xs text-ink-3">POST /admin/quality/recompute — recomputes completeness, primary-source ratio, freshness and conflicts for every entity. May take a while on a large database.</p>
39+ </div>
40+ <form action={recomputeQualityAction}>
41+ <ActionButton>Recompute quality</ActionButton>
42+ </form>
43+ </li>
44+ </ul>
45+ </>
46+ );
47+}
added apps/web/src/app/admin/connectors/page.tsx +120 −0
@@ -0,0 +1,120 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminTitle, Bool, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
4+import { TierBadge } from '@/components/ui/badges';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { LiveAgo } from '@/components/ui/live';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { runConnectorAction, toggleConnectorAction } from '@/lib/admin/actions';
9+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10+import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
11+
12+export const metadata: Metadata = { title: 'Connectors', robots: { index: false, follow: false } };
13+export const dynamic = 'force-dynamic';
14+
15+const RETURN = '/admin/connectors';
16+
17+export default async function AdminConnectorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
18+ await requireAdmin();
19+ const sp = await searchParams;
20+ const res = await load(adminApi.connectors());
21+ if (!res.ok) {
22+ return (
23+ <>
24+ <AdminTitle title="Connectors" />
25+ <Unavailable what="Connectors" reason={res.error} />
26+ </>
27+ );
28+ }
29+ const items = [...res.data.items].sort((a, b) => (num(a.priority) ?? 9) - (num(b.priority) ?? 9) || a.name.localeCompare(b.name));
30+ const counts = items.reduce<Record<string, number>>((acc, c) => ((acc[c.health] = (acc[c.health] ?? 0) + 1), acc), {});
31+ return (
32+ <>
33+ <AdminTitle title="Connectors" count={fmtInt(items.length)} lede={Object.entries(counts).map(([k, n]) => `${k} ${n}`).join(' · ')} />
34+ <Notice notice={sp.notice} level={sp.level} />
35+ <DataTable compact scroll caption="Connectors">
36+ <thead>
37+ <tr>
38+ <Th>Connector</Th>
39+ <Th>Source</Th>
40+ <Th>Health</Th>
41+ <Th>Enabled</Th>
42+ <Th num>Prio</Th>
43+ <Th num>Interval</Th>
44+ <Th>Last attempt</Th>
45+ <Th>Last success</Th>
46+ <Th>Next run</Th>
47+ <Th>Last run</Th>
48+ <Th num>Docs / snaps</Th>
49+ <Th num>Fails</Th>
50+ <Th>Circuit</Th>
51+ <Th>Actions</Th>
52+ </tr>
53+ </thead>
54+ <tbody>
55+ {items.length === 0 && <EmptyRow cols={14}>No connectors registered.</EmptyRow>}
56+ {items.map((c) => {
57+ const lr = c.last_run;
58+ return (
59+ <tr key={c.name}>
60+ <Td>
61+ <div className="flex items-center gap-1.5">
62+ <Link href={`/admin/runs?connector=${encodeURIComponent(c.name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{c.name}</Link>
63+ {c.run_now_pending && <span className="text-[10px] uppercase tracking-wide text-accent" title="Run requested; waiting for the scheduler tick">queued</span>}
64+ {c.in_code === false && <span className="text-[10px] uppercase tracking-wide text-warning" title="Registered in the database but not present in code">no code</span>}
65+ </div>
66+ <span className="block max-w-[16rem] truncate text-[11px] text-ink-3" title={c.label}>{c.label}</span>
67+ </Td>
68+ <Td className="text-xs">
69+ <span className="text-ink-2">{c.source_name ?? c.source_key ?? '—'}</span> {c.source_tier !== undefined && c.source_tier !== null && <TierBadge tier={num(c.source_tier)} />}
70+ </Td>
71+ <Td><StatusChip value={c.health} /></Td>
72+ <Td><Bool v={c.enabled} /></Td>
73+ <Td num className="tnum text-xs">{fmtInt(c.priority)}</Td>
74+ <Td num className="tnum text-xs">{fmtDuration(c.interval_seconds)}</Td>
75+ <Td className="text-xs" title={fmtDateTime(c.last_attempt_at)}><LiveAgo at={c.last_attempt_at} /></Td>
76+ <Td className="text-xs" title={fmtDateTime(c.last_success_at)}><LiveAgo at={c.last_success_at} /></Td>
77+ <Td className="text-xs text-ink-2" title={fmtDateTime(c.next_run_at)}>{c.next_run_at ? fmtDateTime(c.next_run_at).replace(/ UTC$/, '') : '—'}</Td>
78+ <Td className="text-xs">
79+ {lr ? (
80+ <span className="inline-flex flex-wrap items-center gap-1.5">
81+ <StatusChip value={lr.status} />
82+ <span className="tnum text-ink-3">{num(lr.duration_ms) === null ? '' : fmtDuration(Math.round((num(lr.duration_ms) ?? 0) / 1000))}</span>
83+ <span className="tnum text-ink-3" title="docs changed / fetched">{fmtInt(lr.docs_changed)}/{fmtInt(lr.docs_fetched)}</span>
84+ {lr.error && <Trunc text={lr.error} max={40} className="text-danger" />}
85+ </span>
86+ ) : (
87+ <span className="text-ink-3">—</span>
88+ )}
89+ </Td>
90+ <Td num className="tnum text-xs text-ink-2">{c.documents === undefined && c.snapshots === undefined ? '—' : `${fmtInt(c.documents)} / ${fmtInt(c.snapshots)}`}</Td>
91+ <Td num className={`tnum text-xs ${num(c.consecutive_failures) ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(c.consecutive_failures)}</Td>
92+ <Td className="text-xs">{c.circuit_open_until ? <span className="text-warning" title={fmtDateTime(c.circuit_open_until)}>open · <LiveAgo at={c.circuit_open_until} /></span> : <span className="text-ink-3">closed</span>}</Td>
93+ <Td>
94+ <div className="flex items-center gap-1.5">
95+ <form action={runConnectorAction}>
96+ <input type="hidden" name="name" value={c.name} />
97+ <input type="hidden" name="return" value={RETURN} />
98+ <ActionButton tone="accent" disabled={!!c.run_now_pending} title="Enqueue an immediate run (force)">Run now</ActionButton>
99+ </form>
100+ <form action={toggleConnectorAction}>
101+ <input type="hidden" name="name" value={c.name} />
102+ <input type="hidden" name="enabled" value={c.enabled ? 'false' : 'true'} />
103+ <input type="hidden" name="return" value={RETURN} />
104+ <ActionButton tone={c.enabled ? 'danger' : 'positive'}>{c.enabled ? 'Disable' : 'Enable'}</ActionButton>
105+ </form>
106+ </div>
107+ </Td>
108+ </tr>
109+ );
110+ })}
111+ </tbody>
112+ </DataTable>
113+ {res.data.unregistered_in_db && res.data.unregistered_in_db.length > 0 && (
114+ <p className="mt-4 text-xs text-ink-3">
115+ In code but not in the database ({res.data.unregistered_in_db.length}): {res.data.unregistered_in_db.map((n) => <Mono key={n} className="mr-1.5">{n}</Mono>)} — run <Mono>aia seed</Mono> to register them.
116+ </p>
117+ )}
118+ </>
119+ );
120+}
added apps/web/src/app/admin/documents/[id]/page.tsx +114 −0
@@ -0,0 +1,114 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { AdminTitle, Bool, JsonPre, Mono, StatusChip } from '@/components/admin/ui';
5+import { TierBadge } from '@/components/ui/badges';
6+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
7+import { KeyValue } from '@/components/ui/key-value';
8+import { LiveAgo } from '@/components/ui/live';
9+import { Unavailable } from '@/components/ui/unavailable';
10+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
11+import type { AdminSnapshotRow } from '@/lib/admin/types';
12+import { fmtBytes, fmtDateTime, fmtInt, num } from '@/lib/format';
13+import { routes } from '@/lib/site';
14+
15+export const metadata: Metadata = { title: 'Document', robots: { index: false, follow: false } };
16+export const dynamic = 'force-dynamic';
17+
18+export default async function AdminDocumentPage({ params }: { params: Promise<{ id: string }> }) {
19+ await requireAdmin();
20+ const { id } = await params;
21+ const res = await load(adminApi.document(id));
22+ if (!res.ok && res.error.startsWith('404')) notFound();
23+ if (!res.ok) {
24+ return (
25+ <>
26+ <AdminTitle title="Document" />
27+ <Unavailable what="Document" reason={res.error} />
28+ </>
29+ );
30+ }
31+ const d = res.data;
32+ const snaps: AdminSnapshotRow[] = Array.isArray(d.snapshots) ? d.snapshots : [];
33+ const meta = { ...(d.meta ?? {}) } as Record<string, unknown>;
34+ delete meta.raw_path;
35+ delete meta.text_path;
36+ return (
37+ <>
38+ <p className="mb-2 text-xs"><Link href="/admin/documents" className="link">← Documents</Link></p>
39+ <AdminTitle title={d.title ?? d.url.replace(/^https?:\/\/(www\.)?/, '')} lede={<Mono>{d.id}</Mono>} />
40+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
41+ <div className="min-w-0">
42+ <KeyValue
43+ dense
44+ rows={[
45+ { key: 'url', label: 'URL', value: <a href={d.url} target="_blank" rel="noopener noreferrer" className="link break-all">{d.url}</a> },
46+ { key: 'canonical_url', label: 'Canonical', raw: d.canonical_url },
47+ { key: 'connector', label: 'Connector', value: <Link href={`/admin/documents?connector=${encodeURIComponent(d.connector_name ?? '')}`} className="mono text-xs hover:text-accent">{d.connector_name ?? '—'}</Link> },
48+ { key: 'source', label: 'Source', value: <span className="text-sm">{d.source_name ?? d.source_id ?? '—'} {d.source_tier !== undefined && d.source_tier !== null && <TierBadge tier={num(d.source_tier)} />}</span> },
49+ { key: 'doc_type', label: 'Type', raw: d.doc_type },
50+ { key: 'entity', label: 'Entity', value: d.entity_slug && d.entity_type ? <Link href={routes.entity({ entity_type: d.entity_type, slug: d.entity_slug })} className="link">{d.entity_name ?? d.entity_slug}</Link> : <span className="text-ink-3">—</span> },
51+ { key: 'status', label: 'Status', value: <StatusChip value={d.status} /> },
52+ { key: 'processing', label: 'Processing', value: <StatusChip value={d.last_processing_status ?? null} /> },
53+ { key: 'first_seen_at', label: 'First seen', raw: d.first_seen_at ? fmtDateTime(d.first_seen_at) : null },
54+ { key: 'last_fetched_at', label: 'Last fetched', raw: d.last_fetched_at ? fmtDateTime(d.last_fetched_at) : null },
55+ { key: 'last_changed_at', label: 'Last changed', raw: d.last_changed_at ? fmtDateTime(d.last_changed_at) : null },
56+ { key: 'next_fetch_at', label: 'Next fetch', raw: d.next_fetch_at ? fmtDateTime(d.next_fetch_at) : null },
57+ { key: 'counts', label: 'Fetch / change / fail', value: <span className="tnum">{fmtInt(d.fetch_count)} / {fmtInt(d.change_count)} / {fmtInt(d.fail_count)}</span> },
58+ { key: 'last_status', label: 'Last HTTP', raw: d.last_status },
59+ { key: 'etag', label: 'ETag', value: d.etag ? <Mono>{d.etag}</Mono> : undefined },
60+ { key: 'content_hash', label: 'Content hash', value: d.content_hash ? <Mono className="break-all">{d.content_hash}</Mono> : undefined },
61+ { key: 'priority', label: 'Priority', raw: d.priority },
62+ { key: 'needs_llm', label: 'Needs LLM', value: <Bool v={d.needs_llm} /> },
63+ ]}
64+ />
65+ </div>
66+ <aside className="min-w-0">
67+ <p className="eyebrow mb-2">Meta</p>
68+ <JsonPre value={Object.keys(meta).length ? meta : null} maxHeight="20rem" />
69+ </aside>
70+ </div>
71+
72+ <section className="mt-8">
73+ <p className="eyebrow mb-2">Snapshots <span className="tnum text-ink-3">{snaps.length}</span></p>
74+ <DataTable compact scroll caption="Snapshots">
75+ <thead>
76+ <tr>
77+ <Th>Observed</Th>
78+ <Th num>HTTP</Th>
79+ <Th num>Bytes</Th>
80+ <Th>Changed</Th>
81+ <Th>Processing</Th>
82+ <Th>Parser</Th>
83+ <Th>Transport</Th>
84+ <Th>Structured</Th>
85+ <Th>Text</Th>
86+ <Th>Run</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {snaps.length === 0 && <EmptyRow cols={10}>No snapshots archived for this document.</EmptyRow>}
91+ {snaps.map((s) => (
92+ <tr key={s.id}>
93+ <Td>
94+ <Link href={`/admin/snapshots/${encodeURIComponent(s.id)}`} className="text-xs text-ink hover:text-accent" title={s.id}>
95+ {fmtDateTime(s.observed_at)} · <LiveAgo at={s.observed_at} />
96+ </Link>
97+ </Td>
98+ <Td num className={`tnum text-xs ${(num(s.http_status) ?? 0) >= 400 ? 'text-danger' : ''}`}>{fmtInt(s.http_status)}</Td>
99+ <Td num className="tnum text-xs">{fmtBytes(s.byte_size)}</Td>
100+ <Td><Bool v={s.changed} /></Td>
101+ <Td><StatusChip value={s.processing_status} /></Td>
102+ <Td><Mono>v{s.parser_version ?? '?'}</Mono></Td>
103+ <Td><Mono>{s.transport ?? '—'}</Mono></Td>
104+ <Td><Bool v={s.has_structured} /></Td>
105+ <Td><Bool v={s.has_text} /></Td>
106+ <Td>{s.run_id ? <Mono>{s.run_id}</Mono> : <span className="text-ink-3">—</span>}</Td>
107+ </tr>
108+ ))}
109+ </tbody>
110+ </DataTable>
111+ </section>
112+ </>
113+ );
114+}
added apps/web/src/app/admin/documents/page.tsx +96 −0
@@ -0,0 +1,96 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminFilters, AdminTitle, Mono, StatusChip } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Pagination, withParams } from '@/components/ui/pagination';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import { fmtDateTime, fmtInt, num } from '@/lib/format';
10+import { routes } from '@/lib/site';
11+
12+export const metadata: Metadata = { title: 'Documents', robots: { index: false, follow: false } };
13+export const dynamic = 'force-dynamic';
14+
15+const LIMIT = 50;
16+const STATUSES = ['active', 'gone', 'blocked', 'error', 'paused'];
17+
18+export default async function AdminDocumentsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
19+ await requireAdmin();
20+ const sp = await searchParams;
21+ const current: Record<string, string | undefined> = {};
22+ for (const k of ['connector', 'status', 'q', 'offset']) if (sp[k]) current[k] = sp[k];
23+ const offset = Math.max(0, Number(current.offset) || 0);
24+ const [docs, connectors] = await Promise.all([load(adminApi.documents({ connector: current.connector, status: current.status, q: current.q, limit: LIMIT, offset })), load(adminApi.connectors())]);
25+ const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
26+ const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/documents', current, patch);
27+ return (
28+ <>
29+ <AdminTitle title="Documents" count={docs.ok ? fmtInt(docs.data.total) : undefined} />
30+ <AdminFilters
31+ action="/admin/documents"
32+ className="mb-4"
33+ fields={[
34+ { kind: 'select', name: 'connector', label: 'Connector', value: current.connector, options: names.map((n) => ({ value: n, label: n })) },
35+ { kind: 'select', name: 'status', label: 'Status', value: current.status, options: STATUSES.map((s) => ({ value: s, label: s })) },
36+ { kind: 'text', name: 'q', label: 'URL / title', value: current.q, placeholder: 'contains…' },
37+ ]}
38+ />
39+ {!docs.ok ? (
40+ <Unavailable what="Documents" reason={docs.error} />
41+ ) : (
42+ <>
43+ <DataTable compact scroll caption="Documents">
44+ <thead>
45+ <tr>
46+ <Th>Document</Th>
47+ <Th>Connector</Th>
48+ <Th>Type</Th>
49+ <Th>Entity</Th>
50+ <Th>Fetched</Th>
51+ <Th num>HTTP</Th>
52+ <Th num>Snaps</Th>
53+ <Th num>Changes</Th>
54+ <Th num>Fails</Th>
55+ <Th>Status</Th>
56+ <Th>Processing</Th>
57+ </tr>
58+ </thead>
59+ <tbody>
60+ {docs.data.items.length === 0 && <EmptyRow cols={11}>No documents match.</EmptyRow>}
61+ {docs.data.items.map((d) => (
62+ <tr key={d.id}>
63+ <Td>
64+ <Link href={`/admin/documents/${encodeURIComponent(d.id)}`} className="block max-w-[22rem] truncate text-xs font-medium text-ink hover:text-accent" title={d.title ?? d.url}>
65+ {d.title ?? d.url.replace(/^https?:\/\/(www\.)?/, '')}
66+ </Link>
67+ <span className="block max-w-[22rem] truncate text-[11px] text-ink-3" title={d.url}>{d.url.replace(/^https?:\/\/(www\.)?/, '')}</span>
68+ </Td>
69+ <Td><Mono>{d.connector_name ?? '—'}</Mono></Td>
70+ <Td><Mono>{d.doc_type ?? '—'}</Mono></Td>
71+ <Td className="text-xs">
72+ {d.entity_slug && d.entity_type ? (
73+ <Link href={routes.entity({ entity_type: d.entity_type, slug: d.entity_slug })} className="text-ink-2 hover:text-accent" title={d.entity_id ?? undefined}>
74+ {d.entity_name ?? d.entity_slug}
75+ </Link>
76+ ) : (
77+ <span className="text-ink-3">—</span>
78+ )}
79+ </Td>
80+ <Td className="text-xs" title={fmtDateTime(d.last_fetched_at)}><LiveAgo at={d.last_fetched_at} /></Td>
81+ <Td num className={`tnum text-xs ${(num(d.last_status) ?? 0) >= 400 ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(d.last_status)}</Td>
82+ <Td num className="tnum text-xs">{typeof d.snapshots === 'number' ? fmtInt(d.snapshots) : Array.isArray(d.snapshots) ? fmtInt(d.snapshots.length) : '—'}</Td>
83+ <Td num className="tnum text-xs">{fmtInt(d.change_count)}</Td>
84+ <Td num className={`tnum text-xs ${num(d.fail_count) ? 'text-danger' : 'text-ink-2'}`}>{fmtInt(d.fail_count)}</Td>
85+ <Td><StatusChip value={d.status} /></Td>
86+ <Td><StatusChip value={d.last_processing_status ?? null} /></Td>
87+ </tr>
88+ ))}
89+ </tbody>
90+ </DataTable>
91+ <Pagination total={docs.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
92+ </>
93+ )}
94+ </>
95+ );
96+}
added apps/web/src/app/admin/entities/duplicates/page.tsx +108 −0
@@ -0,0 +1,108 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminFilters, AdminTitle, Mono, Notice } from '@/components/admin/ui';
4+import { EntityBadge } from '@/components/ui/badges';
5+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { mergeAction } from '@/lib/admin/actions';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import type { DuplicateSide } from '@/lib/admin/types';
10+import { fmtDate, fmtInt, fmtPct, num } from '@/lib/format';
11+import { routes } from '@/lib/site';
12+
13+export const metadata: Metadata = { title: 'Duplicates', robots: { index: false, follow: false } };
14+export const dynamic = 'force-dynamic';
15+
16+const TYPES = ['model', 'company', 'organization', 'lab', 'paper', 'provider', 'benchmark', 'hardware', 'framework', 'library', 'dataset', 'tool', 'repository', 'researcher'];
17+
18+function Side({ s, type }: { s: DuplicateSide; type: string }) {
19+ return (
20+ <div className="min-w-0">
21+ <Link href={routes.entity({ entity_type: type, slug: s.slug })} className="block truncate text-sm font-medium text-ink hover:text-accent" title={s.name}>
22+ {s.name}
23+ </Link>
24+ <p className="truncate text-[11px] text-ink-3">
25+ <Mono>{s.slug}</Mono>
26+ {s.organization && <span> · {s.organization}</span>}
27+ </p>
28+ <p className="tnum text-[11px] text-ink-3">
29+ {fmtInt(s.claims)} claims · first seen {fmtDate(s.first_seen_at)}
30+ </p>
31+ </div>
32+ );
33+}
34+
35+export default async function AdminDuplicatesPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
36+ await requireAdmin();
37+ const sp = await searchParams;
38+ const type = sp.type ?? 'model';
39+ const ret = `/admin/entities/duplicates?type=${encodeURIComponent(type)}`;
40+ const res = await load(adminApi.duplicates({ type, limit: 200 }));
41+ return (
42+ <>
43+ <AdminTitle title="Duplicate candidates" count={res.ok ? fmtInt(res.data.items.length) : undefined} lede={res.ok ? `pg_trgm similarity ≥ ${fmtPct((num(res.data.threshold) ?? 0) * 100, 0)} on normalized names, within one type. Merging moves aliases, identifiers, claims, relations and events into the target and marks the source merged_into.` : undefined} />
44+ <Notice notice={sp.notice} level={sp.level} />
45+ <AdminFilters action="/admin/entities/duplicates" className="mb-4" fields={[{ kind: 'select', name: 'type', label: 'Type', value: type, any: 'model', options: TYPES.map((t) => ({ value: t, label: t })) }]} />
46+ {!res.ok ? (
47+ <Unavailable what="Duplicates" reason={res.error} />
48+ ) : (
49+ <DataTable compact caption="Duplicate candidates">
50+ <thead>
51+ <tr>
52+ <Th>A</Th>
53+ <Th>B</Th>
54+ <Th num>Similarity</Th>
55+ <Th>Merge</Th>
56+ </tr>
57+ </thead>
58+ <tbody>
59+ {res.data.items.length === 0 && <EmptyRow cols={4}>No candidate pairs for this type.</EmptyRow>}
60+ {res.data.items.map((d) => {
61+ const aClaims = num(d.a.claims) ?? 0;
62+ const bClaims = num(d.b.claims) ?? 0;
63+ // Default suggestion: the record with fewer claims (or the newer one) is the source, the richer one the target.
64+ const preferAB = aClaims < bClaims || (aClaims === bClaims && (d.a.first_seen_at ?? '') > (d.b.first_seen_at ?? ''));
65+ return (
66+ <tr key={`${d.a.id}-${d.b.id}`}>
67+ <Td label="A" primary>
68+ <div className="flex items-start gap-2">
69+ <EntityBadge type={d.entity_type} small className="mt-0.5" />
70+ <Side s={d.a} type={d.entity_type} />
71+ </div>
72+ </Td>
73+ <Td label="B" primary>
74+ <div className="flex items-start gap-2">
75+ <EntityBadge type={d.entity_type} small className="mt-0.5" />
76+ <Side s={d.b} type={d.entity_type} />
77+ </div>
78+ </Td>
79+ <Td num label="Similarity" className="tnum text-xs">{fmtPct((num(d.similarity) ?? 0) * 100, 0)}</Td>
80+ <Td label="Merge" wide>
81+ <div className="flex flex-wrap items-center gap-1.5">
82+ <form action={mergeAction}>
83+ <input type="hidden" name="source_id" value={d.a.id} />
84+ <input type="hidden" name="target_id" value={d.b.id} />
85+ <input type="hidden" name="return" value={ret} />
86+ <ActionButton tone={preferAB ? 'accent' : 'neutral'} title={`Merge ${d.a.slug} into ${d.b.slug} (A disappears, B keeps everything)`}>
87+ Merge A → B{preferAB ? ' · suggested' : ''}
88+ </ActionButton>
89+ </form>
90+ <form action={mergeAction}>
91+ <input type="hidden" name="source_id" value={d.b.id} />
92+ <input type="hidden" name="target_id" value={d.a.id} />
93+ <input type="hidden" name="return" value={ret} />
94+ <ActionButton tone={preferAB ? 'neutral' : 'accent'} title={`Merge ${d.b.slug} into ${d.a.slug} (B disappears, A keeps everything)`}>
95+ Merge B → A{preferAB ? '' : ' · suggested'}
96+ </ActionButton>
97+ </form>
98+ </div>
99+ </Td>
100+ </tr>
101+ );
102+ })}
103+ </tbody>
104+ </DataTable>
105+ )}
106+ </>
107+ );
108+}
added apps/web/src/app/admin/errors/page.tsx +69 −0
@@ -0,0 +1,69 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminFilters, AdminTitle, KindChip, Mono, Trunc } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtDateTime, fmtInt } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Errors', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+export default async function AdminErrorsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
14+ await requireAdmin();
15+ const sp = await searchParams;
16+ const limit = Math.min(500, Math.max(10, Number(sp.limit) || 100));
17+ const [errs, connectors] = await Promise.all([load(adminApi.errors({ connector: sp.connector, limit })), load(adminApi.connectors())]);
18+ const names = connectors.ok ? connectors.data.items.map((c) => c.name).sort() : [];
19+ return (
20+ <>
21+ <AdminTitle title="Errors" count={errs.ok ? fmtInt(errs.data.total) : undefined} />
22+ <AdminFilters
23+ action="/admin/errors"
24+ className="mb-4"
25+ fields={[
26+ { kind: 'select', name: 'connector', label: 'Connector', value: sp.connector, options: names.map((n) => ({ value: n, label: n })) },
27+ { kind: 'select', name: 'limit', label: 'Rows', value: String(limit), any: '100', options: ['50', '100', '250', '500'].map((v) => ({ value: v, label: v })) },
28+ ]}
29+ />
30+ {!errs.ok ? (
31+ <Unavailable what="Errors" reason={errs.error} />
32+ ) : (
33+ <DataTable compact scroll caption="Fetch and parse errors">
34+ <thead>
35+ <tr>
36+ <Th>Time</Th>
37+ <Th>Connector</Th>
38+ <Th>Type</Th>
39+ <Th>Message</Th>
40+ <Th>URL</Th>
41+ <Th>Run</Th>
42+ </tr>
43+ </thead>
44+ <tbody>
45+ {errs.data.items.length === 0 && <EmptyRow cols={6}>No errors recorded.</EmptyRow>}
46+ {errs.data.items.map((e) => (
47+ <tr key={String(e.id)}>
48+ <Td className="text-xs" title={fmtDateTime(e.created_at)}><LiveAgo at={e.created_at} /></Td>
49+ <Td>{e.connector_name ? <Link href={`/admin/errors?connector=${encodeURIComponent(e.connector_name)}`} className="mono text-xs text-ink hover:text-accent">{e.connector_name}</Link> : <span className="text-ink-3">—</span>}</Td>
50+ <Td>{e.error_type ? <KindChip value={e.error_type} /> : <span className="text-ink-3">—</span>}</Td>
51+ <Td className="text-xs text-ink-2"><Trunc text={e.message} max={120} /></Td>
52+ <Td className="text-xs">
53+ {e.url ? (
54+ <a href={e.url} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent" title={e.url}>
55+ {e.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 60)}
56+ </a>
57+ ) : (
58+ <span className="text-ink-3">—</span>
59+ )}
60+ </Td>
61+ <Td>{e.run_id ? <Mono>{e.run_id}</Mono> : <span className="text-ink-3">—</span>}</Td>
62+ </tr>
63+ ))}
64+ </tbody>
65+ </DataTable>
66+ )}
67+ </>
68+ );
69+}
added apps/web/src/app/admin/infrastructure/page.tsx +104 −0
@@ -0,0 +1,104 @@
1+import type { Metadata } from 'next';
2+import { AdminTitle, Bool, JsonPre, Mono } from '@/components/admin/ui';
3+import { HBars } from '@/components/charts/charts';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { KeyValue } from '@/components/ui/key-value';
6+import { Unavailable } from '@/components/ui/unavailable';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtBytes, fmtDuration, fmtInt, num } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Infrastructure', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+export default async function AdminInfrastructurePage() {
14+ await requireAdmin();
15+ const res = await load(adminApi.infrastructure());
16+ if (!res.ok) {
17+ return (
18+ <>
19+ <AdminTitle title="Infrastructure" />
20+ <Unavailable what="Infrastructure" reason={res.error} />
21+ </>
22+ );
23+ }
24+ const i = res.data;
25+ const db = i.database;
26+ const tables = [...(db?.tables ?? [])].sort((a, b) => (num(b.total_bytes) ?? 0) - (num(a.total_bytes) ?? 0));
27+ const hb = Object.entries(i.heartbeats ?? {});
28+ return (
29+ <>
30+ <AdminTitle title="Infrastructure" lede={i.hostname ? <Mono>{i.hostname}</Mono> : undefined} />
31+ <div className="grid gap-8 lg:grid-cols-2">
32+ <section>
33+ <p className="eyebrow mb-2">API process</p>
34+ <KeyValue
35+ dense
36+ rows={[
37+ { key: 'hostname', label: 'Host', value: <Mono>{i.hostname ?? '—'}</Mono> },
38+ { key: 'env', label: 'Environment', raw: i.env },
39+ { key: 'python', label: 'Python', raw: i.python },
40+ { key: 'platform', label: 'Platform', raw: i.platform },
41+ { key: 'uptime', label: 'API uptime', raw: num(i.api_uptime_s) === null ? null : fmtDuration(i.api_uptime_s) },
42+ { key: 'data_dir', label: 'Data dir', value: <Bool v={i.data_dir_exists} /> },
43+ ]}
44+ />
45+ <p className="eyebrow mt-6 mb-2">Archive</p>
46+ {i.archive ? (
47+ <KeyValue
48+ dense
49+ rows={[
50+ { key: 'raw', label: 'Raw', value: <span className="tnum">{fmtBytes(i.archive.raw_bytes)} · {fmtInt(i.archive.raw_files)} files</span> },
51+ { key: 'text', label: 'Text', value: <span className="tnum">{fmtBytes(i.archive.text_bytes)} · {fmtInt(i.archive.text_files)} files</span> },
52+ ]}
53+ />
54+ ) : (
55+ <p className="text-sm text-ink-3">Archive size unavailable.</p>
56+ )}
57+ <p className="eyebrow mt-6 mb-2">Heartbeats</p>
58+ {hb.length === 0 ? <p className="text-sm text-ink-3">None reported.</p> : <JsonPre value={i.heartbeats} maxHeight="14rem" />}
59+ </section>
60+ <section>
61+ <p className="eyebrow mb-2">Database</p>
62+ {db ? (
63+ <KeyValue
64+ dense
65+ rows={[
66+ { key: 'database', label: 'Database', value: <Mono>{db.database ?? '—'}</Mono> },
67+ { key: 'size', label: 'Size', raw: fmtBytes(db.db_bytes) },
68+ { key: 'pg', label: 'Server', raw: db.pg_version?.split(',')[0] ?? db.pg_version },
69+ { key: 'conn', label: 'Connections', value: <span className="tnum">{fmtInt(db.connections)} / {fmtInt(db.max_connections)}</span> },
70+ ]}
71+ />
72+ ) : (
73+ <p className="text-sm text-ink-3">Database metrics unavailable.</p>
74+ )}
75+ <p className="eyebrow mt-6 mb-2">Table sizes</p>
76+ <HBars data={tables.slice(0, 12).map((t) => ({ label: t.table, value: num(t.total_bytes) ?? 0, sub: `${fmtInt(t.rows_estimate)} rows` }))} format={(v) => fmtBytes(v)} />
77+ </section>
78+ </div>
79+ <section className="mt-8">
80+ <DataTable compact caption="Table sizes">
81+ <thead>
82+ <tr>
83+ <Th>Table</Th>
84+ <Th num>Total</Th>
85+ <Th num>Data</Th>
86+ <Th num>Rows (est.)</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {tables.length === 0 && <EmptyRow cols={4}>No table statistics.</EmptyRow>}
91+ {tables.map((t) => (
92+ <tr key={t.table}>
93+ <Td primary><Mono className="text-ink">{t.table}</Mono></Td>
94+ <Td num label="Total" className="tnum text-xs">{fmtBytes(t.total_bytes)}</Td>
95+ <Td num label="Data" className="tnum text-xs text-ink-2">{fmtBytes(t.data_bytes)}</Td>
96+ <Td num label="Rows" className="tnum text-xs">{fmtInt(t.rows_estimate)}</Td>
97+ </tr>
98+ ))}
99+ </tbody>
100+ </DataTable>
101+ </section>
102+ </>
103+ );
104+}
added apps/web/src/app/admin/jobs/page.tsx +108 −0
@@ -0,0 +1,108 @@
1+import type { Metadata } from 'next';
2+import { ActionButton, AdminFilters, AdminTitle, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
3+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4+import { LiveAgo } from '@/components/ui/live';
5+import { Unavailable } from '@/components/ui/unavailable';
6+import { requeueDeadAction, retryJobAction } from '@/lib/admin/actions';
7+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8+import { fmtDateTime, fmtInt } from '@/lib/format';
9+
10+export const metadata: Metadata = { title: 'Jobs', robots: { index: false, follow: false } };
11+export const dynamic = 'force-dynamic';
12+
13+const STATUSES = ['queued', 'running', 'done', 'failed', 'dead'];
14+
15+function payloadSummary(p: Record<string, unknown>): string {
16+ return Object.entries(p ?? {})
17+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
18+ .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
19+ .join(' · ');
20+}
21+
22+export default async function AdminJobsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
23+ await requireAdmin();
24+ const sp = await searchParams;
25+ const ret = `/admin/jobs${sp.status || sp.kind ? `?${new URLSearchParams(Object.fromEntries(Object.entries({ status: sp.status, kind: sp.kind }).filter(([, v]) => v)) as Record<string, string>).toString()}` : ''}`;
26+ const res = await load(adminApi.jobs({ status: sp.status, kind: sp.kind, limit: 200 }));
27+ const kinds = res.ok ? Object.keys(res.data.depth ?? {}) : [];
28+ return (
29+ <>
30+ <AdminTitle title="Jobs" count={res.ok ? fmtInt(res.data.total) : undefined}>
31+ <form action={requeueDeadAction}>
32+ <input type="hidden" name="return" value={ret} />
33+ <ActionButton tone="accent" title="Move every dead job back to the queue">Requeue dead</ActionButton>
34+ </form>
35+ </AdminTitle>
36+ <Notice notice={sp.notice} level={sp.level} />
37+ {res.ok && res.data.depth && Object.keys(res.data.depth).length > 0 && (
38+ <ul className="mb-4 flex flex-wrap gap-x-4 gap-y-1 text-xs">
39+ {Object.entries(res.data.depth).map(([kind, byStatus]) => (
40+ <li key={kind} className="flex flex-wrap items-center gap-1.5">
41+ <Mono className="text-ink">{kind}</Mono>
42+ {Object.entries(byStatus).map(([st, n]) => (
43+ <a key={st} href={`/admin/jobs?kind=${encodeURIComponent(kind)}&status=${encodeURIComponent(st)}`} className="inline-flex items-center gap-1 hover:underline">
44+ <StatusChip value={st} /> <span className="tnum text-ink-2">{fmtInt(n)}</span>
45+ </a>
46+ ))}
47+ </li>
48+ ))}
49+ </ul>
50+ )}
51+ <AdminFilters
52+ action="/admin/jobs"
53+ className="mb-4"
54+ fields={[
55+ { kind: 'select', name: 'kind', label: 'Kind', value: sp.kind, options: (kinds.length ? kinds : ['llm_extract', 'reprocess_snapshot']).map((k) => ({ value: k, label: k })) },
56+ { kind: 'select', name: 'status', label: 'Status', value: sp.status, options: STATUSES.map((s) => ({ value: s, label: s })) },
57+ ]}
58+ />
59+ {!res.ok ? (
60+ <Unavailable what="Jobs" reason={res.error} />
61+ ) : (
62+ <DataTable compact scroll caption="Job queue">
63+ <thead>
64+ <tr>
65+ <Th>Job</Th>
66+ <Th>Kind</Th>
67+ <Th>Status</Th>
68+ <Th num>Prio</Th>
69+ <Th num>Attempts</Th>
70+ <Th>Run after</Th>
71+ <Th>Locked by</Th>
72+ <Th>Started</Th>
73+ <Th>Finished</Th>
74+ <Th>Error</Th>
75+ <Th>Action</Th>
76+ </tr>
77+ </thead>
78+ <tbody>
79+ {res.data.items.length === 0 && <EmptyRow cols={11}>No jobs match.</EmptyRow>}
80+ {res.data.items.map((j) => (
81+ <tr key={j.id}>
82+ <Td><Mono title={payloadSummary(j.payload)}>{j.id}</Mono></Td>
83+ <Td><Mono className="text-ink">{j.kind}</Mono></Td>
84+ <Td><StatusChip value={j.status} /></Td>
85+ <Td num className="tnum text-xs">{fmtInt(j.priority)}</Td>
86+ <Td num className="tnum text-xs">{fmtInt(j.attempts)} / {fmtInt(j.max_attempts)}</Td>
87+ <Td className="text-xs text-ink-2" title={fmtDateTime(j.run_after)}>{j.run_after ? <LiveAgo at={j.run_after} /> : '—'}</Td>
88+ <Td className="text-xs text-ink-2"><Trunc text={j.locked_by} max={28} /></Td>
89+ <Td className="text-xs" title={fmtDateTime(j.started_at)}>{j.started_at ? <LiveAgo at={j.started_at} /> : <span className="text-ink-3">—</span>}</Td>
90+ <Td className="text-xs" title={fmtDateTime(j.finished_at)}>{j.finished_at ? <LiveAgo at={j.finished_at} /> : <span className="text-ink-3">—</span>}</Td>
91+ <Td className="text-xs text-danger"><Trunc text={j.error} max={60} /></Td>
92+ <Td>
93+ {(j.status === 'failed' || j.status === 'dead') && (
94+ <form action={retryJobAction}>
95+ <input type="hidden" name="id" value={j.id} />
96+ <input type="hidden" name="return" value={ret} />
97+ <ActionButton tone="accent">Retry</ActionButton>
98+ </form>
99+ )}
100+ </Td>
101+ </tr>
102+ ))}
103+ </tbody>
104+ </DataTable>
105+ )}
106+ </>
107+ );
108+}
added apps/web/src/app/admin/layout.tsx +14 −0
@@ -0,0 +1,14 @@
1+import type { Metadata } from 'next';
2+import type { ReactNode } from 'react';
3+import { AdminShell } from '@/components/admin/shell';
4+import { Container } from '@/components/ui/section';
5+import { getAdminToken } from '@/lib/admin/session';
6+
7+export const metadata: Metadata = { title: { default: 'Admin', template: '%s · Admin | AI Atlas' }, robots: { index: false, follow: false, nocache: true } };
8+export const dynamic = 'force-dynamic';
9+
10+/** Admin shell when a session cookie exists; bare (login form) otherwise. Every page still validates the token on its own fetches. */
11+export default async function AdminLayout({ children }: { children: ReactNode }) {
12+ const token = await getAdminToken();
13+ return <Container wide>{token ? <AdminShell where={process.env.NODE_ENV === 'production' ? 'production' : 'development'}>{children}</AdminShell> : children}</Container>;
14+}
added apps/web/src/app/admin/llm-jobs/page.tsx +113 −0
@@ -0,0 +1,113 @@
1+import type { Metadata } from 'next';
2+import { AdminFilters, AdminTitle, Mono, StatusChip, Trunc } from '@/components/admin/ui';
3+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4+import { LiveAgo } from '@/components/ui/live';
5+import { Unavailable } from '@/components/ui/unavailable';
6+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
7+import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
8+
9+export const metadata: Metadata = { title: 'LLM jobs', robots: { index: false, follow: false } };
10+export const dynamic = 'force-dynamic';
11+
12+export default async function AdminLlmJobsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
13+ await requireAdmin();
14+ const sp = await searchParams;
15+ const limit = Math.min(1000, Math.max(10, Number(sp.limit) || 100));
16+ const [jobs, health] = await Promise.all([load(adminApi.llmJobs({ limit })), load(adminApi.llmHealth())]);
17+ const ms = (v: unknown) => (num(v) === null ? '—' : `${fmtInt(Math.round((num(v) ?? 0) / 1000))} s`);
18+ return (
19+ <>
20+ <AdminTitle
21+ title="LLM jobs"
22+ count={jobs.ok ? fmtInt(jobs.data.total) : undefined}
23+ lede={
24+ health.ok ? (
25+ <span className="inline-flex flex-wrap items-center gap-x-2">
26+ <StatusChip value={health.data.reachable === false ? 'failing' : health.data.available ? 'ok' : 'disabled'} />
27+ <span>engine {health.data.engine ?? '—'}</span>
28+ {health.data.models && <span>· {Object.entries(health.data.models).map(([k, v]) => `${k}: ${v}`).join(' · ')}</span>}
29+ {health.data.embedding_model && <span>· embeddings {health.data.embedding_model}</span>}
30+ </span>
31+ ) : (
32+ `LLM health unavailable: ${health.error}`
33+ )
34+ }
35+ />
36+ <AdminFilters action="/admin/llm-jobs" className="mb-4" fields={[{ kind: 'select', name: 'limit', label: 'Rows', value: String(limit), any: '100', options: ['50', '100', '250', '500', '1000'].map((v) => ({ value: v, label: v })) }]} />
37+ {!jobs.ok ? (
38+ <Unavailable what="LLM jobs" reason={jobs.error} />
39+ ) : (
40+ <>
41+ {jobs.data.totals && jobs.data.totals.length > 0 && (
42+ <section className="mb-6">
43+ <p className="eyebrow mb-2">Totals by stage · model · status</p>
44+ <DataTable compact scroll caption="LLM totals">
45+ <thead>
46+ <tr>
47+ <Th>Stage</Th>
48+ <Th>Model</Th>
49+ <Th>Status</Th>
50+ <Th num>n</Th>
51+ <Th num>Input tokens</Th>
52+ <Th num>Output tokens</Th>
53+ <Th num>Avg duration</Th>
54+ </tr>
55+ </thead>
56+ <tbody>
57+ {jobs.data.totals.map((t, i) => (
58+ <tr key={i}>
59+ <Td><Mono className="text-ink">{t.stage}</Mono></Td>
60+ <Td><Mono>{t.model}</Mono></Td>
61+ <Td><StatusChip value={t.status} /></Td>
62+ <Td num className="tnum text-xs">{fmtInt(t.n)}</Td>
63+ <Td num className="tnum text-xs">{fmtInt(t.input_tokens)}</Td>
64+ <Td num className="tnum text-xs">{fmtInt(t.output_tokens)}</Td>
65+ <Td num className="tnum text-xs">{ms(t.avg_ms)}</Td>
66+ </tr>
67+ ))}
68+ </tbody>
69+ </DataTable>
70+ </section>
71+ )}
72+ <DataTable compact scroll caption="LLM job accounting">
73+ <thead>
74+ <tr>
75+ <Th>Created</Th>
76+ <Th>Task</Th>
77+ <Th>Stage</Th>
78+ <Th>Model</Th>
79+ <Th>Node</Th>
80+ <Th>Schema</Th>
81+ <Th num>In</Th>
82+ <Th num>Out</Th>
83+ <Th num>Duration</Th>
84+ <Th>Status</Th>
85+ <Th>Error</Th>
86+ <Th>Snapshot</Th>
87+ </tr>
88+ </thead>
89+ <tbody>
90+ {jobs.data.items.length === 0 && <EmptyRow cols={12}>No LLM jobs recorded.</EmptyRow>}
91+ {jobs.data.items.map((j) => (
92+ <tr key={j.id}>
93+ <Td className="text-xs" title={fmtDateTime(j.created_at)}><LiveAgo at={j.created_at} /></Td>
94+ <Td><Mono className="text-ink">{j.task_type ?? '—'}</Mono></Td>
95+ <Td><Mono>{j.stage ?? '—'}</Mono></Td>
96+ <Td><Mono>{j.model ?? '—'}</Mono></Td>
97+ <Td className="text-xs text-ink-2"><Trunc text={j.node} max={24} /></Td>
98+ <Td><Mono>{j.schema_name ?? '—'}</Mono></Td>
99+ <Td num className="tnum text-xs">{fmtInt(j.input_tokens)}</Td>
100+ <Td num className="tnum text-xs">{fmtInt(j.output_tokens)}</Td>
101+ <Td num className="tnum text-xs">{num(j.duration_ms) === null ? '—' : fmtDuration(Math.round((num(j.duration_ms) ?? 0) / 1000))}</Td>
102+ <Td><StatusChip value={j.status} /></Td>
103+ <Td className="text-xs text-danger"><Trunc text={j.error} max={50} /></Td>
104+ <Td>{j.snapshot_id ? <a href={`/admin/snapshots/${encodeURIComponent(j.snapshot_id)}`} className="mono text-[11.5px] text-ink-2 hover:text-accent">{j.snapshot_id}</a> : <span className="text-ink-3">—</span>}</Td>
105+ </tr>
106+ ))}
107+ </tbody>
108+ </DataTable>
109+ </>
110+ )}
111+ </>
112+ );
113+}
added apps/web/src/app/admin/overview/page.tsx +184 −0
@@ -0,0 +1,184 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { AdminTitle, JsonPre, Mono, StatusChip, Trunc } from '@/components/admin/ui';
4+import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Stat, StatGrid } from '@/components/ui/section';
7+import { Unavailable } from '@/components/ui/unavailable';
8+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
9+import { fmtBytes, fmtDuration, fmtInt, num } from '@/lib/format';
10+
11+export const metadata: Metadata = { title: 'Overview', robots: { index: false, follow: false } };
12+export const dynamic = 'force-dynamic';
13+
14+export default async function AdminOverviewPage() {
15+ await requireAdmin();
16+ const res = await load(adminApi.overview());
17+ if (!res.ok) {
18+ return (
19+ <>
20+ <AdminTitle title="Overview" />
21+ <Unavailable what="Overview" reason={res.error} />
22+ </>
23+ );
24+ }
25+ const o = res.data;
26+ const s = o.stats ?? {};
27+ const hb = Object.entries(o.heartbeats ?? {});
28+ const queue = Object.entries(o.queue ?? {});
29+ const health = Object.entries(o.connectors ?? {});
30+ return (
31+ <>
32+ <AdminTitle title="Overview" lede={s.computed_at ? <>stats computed <LiveAgo at={String(s.computed_at)} /></> : undefined} />
33+ <StatGrid cols={4}>
34+ <Stat label="Entities" value={fmtInt(s.entities_total)} />
35+ <Stat label="Documents" value={fmtInt(s.documents)} hint={`${fmtInt(s.snapshots)} snapshots`} />
36+ <Stat label="Current claims" value={fmtInt(s.claims_current)} hint={`${fmtInt(s.claims)} total`} />
37+ <Stat label="Events · 24 h" value={fmtInt(s.change_events_24h)} hint={`${fmtInt(s.change_events)} total`} />
38+ <Stat label="Current prices" value={fmtInt(s.prices_current)} />
39+ <Stat label="Benchmark results" value={fmtInt(s.benchmark_results)} />
40+ <Stat label="Review pending" value={fmtInt(o.review_pending)} href="/admin/review" />
41+ <Stat label="Recent errors" value={fmtInt(o.recent_errors)} href="/admin/errors" />
42+ </StatGrid>
43+
44+ <div className="mt-8 grid gap-8 lg:grid-cols-3">
45+ <section>
46+ <p className="eyebrow mb-2">Queue depth</p>
47+ {queue.length === 0 ? (
48+ <p className="text-sm text-ink-3">Queue empty.</p>
49+ ) : (
50+ <DataTable compact stack={false} caption="Queue depth">
51+ <thead>
52+ <tr>
53+ <Th>Kind</Th>
54+ <Th>Status</Th>
55+ <Th num>n</Th>
56+ </tr>
57+ </thead>
58+ <tbody>
59+ {queue.flatMap(([kind, byStatus]) =>
60+ Object.entries(byStatus).map(([st, n]) => (
61+ <tr key={`${kind}-${st}`}>
62+ <Td><Mono>{kind}</Mono></Td>
63+ <Td><StatusChip value={st} /></Td>
64+ <Td num className="tnum">{fmtInt(n)}</Td>
65+ </tr>
66+ )),
67+ )}
68+ </tbody>
69+ </DataTable>
70+ )}
71+ <p className="mt-2 text-xs"><Link href="/admin/jobs" className="link">Jobs →</Link></p>
72+ </section>
73+ <section>
74+ <p className="eyebrow mb-2">Connector health</p>
75+ <ul className="divide-y divide-rule border-y border-rule text-sm">
76+ {health.map(([k, n]) => (
77+ <li key={k} className="flex items-center justify-between py-1.5">
78+ <StatusChip value={k} />
79+ <span className="tnum">{fmtInt(n)}</span>
80+ </li>
81+ ))}
82+ {health.length === 0 && <li className="py-2 text-ink-3">No connectors.</li>}
83+ </ul>
84+ <p className="mt-2 text-xs"><Link href="/admin/connectors" className="link">Connectors →</Link></p>
85+ {o.review_by_kind && (
86+ <>
87+ <p className="eyebrow mt-6 mb-2">Review by kind</p>
88+ <ul className="divide-y divide-rule border-y border-rule text-sm">
89+ {Object.entries(o.review_by_kind).map(([k, n]) => (
90+ <li key={k} className="flex items-center justify-between py-1.5">
91+ <Link href={`/admin/review?kind=${encodeURIComponent(k)}`} className="mono text-xs text-ink-2 hover:text-accent">{k}</Link>
92+ <span className="tnum">{fmtInt(n)}</span>
93+ </li>
94+ ))}
95+ </ul>
96+ </>
97+ )}
98+ </section>
99+ <section>
100+ <p className="eyebrow mb-2">Heartbeats</p>
101+ {hb.length === 0 ? <p className="text-sm text-ink-3">None reported (scheduler / workers have not written a heartbeat).</p> : <JsonPre value={o.heartbeats} maxHeight="14rem" />}
102+ {o.archive && (
103+ <>
104+ <p className="eyebrow mt-6 mb-2">Archive</p>
105+ <p className="tnum text-sm text-ink-2">
106+ raw {fmtBytes(o.archive.raw_bytes)} · {fmtInt(o.archive.raw_files)} files · text {fmtBytes(o.archive.text_bytes)} · {fmtInt(o.archive.text_files)} files
107+ </p>
108+ </>
109+ )}
110+ </section>
111+ </div>
112+
113+ <section className="mt-8">
114+ <p className="eyebrow mb-2">
115+ LLM factory · 24 h {o.llm?.available === false && <StatusChip value="unavailable" className="ml-2" />}
116+ </p>
117+ <p className="tnum text-sm text-ink-2">
118+ {fmtInt(o.llm?.jobs_24h)} jobs · {fmtInt(o.llm?.tokens_24h)} tokens{o.llm?.failed_24h !== undefined && <> · <span className={num(o.llm.failed_24h) ? 'text-danger' : ''}>{fmtInt(o.llm.failed_24h)} failed</span></>}
119+ </p>
120+ {o.llm?.by_stage?.length ? (
121+ <DataTable compact stack={false} caption="LLM jobs by stage" className="mt-2 max-w-md">
122+ <thead>
123+ <tr>
124+ <Th>Stage</Th>
125+ <Th>Status</Th>
126+ <Th num>n</Th>
127+ </tr>
128+ </thead>
129+ <tbody>
130+ {o.llm.by_stage.map((r, i) => (
131+ <tr key={i}>
132+ <Td><Mono>{r.stage}</Mono></Td>
133+ <Td><StatusChip value={r.status} /></Td>
134+ <Td num className="tnum">{fmtInt(r.n)}</Td>
135+ </tr>
136+ ))}
137+ </tbody>
138+ </DataTable>
139+ ) : (
140+ <p className="mt-2 text-xs text-ink-3">No LLM jobs in the last 24 h.</p>
141+ )}
142+ <p className="mt-2 text-xs"><Link href="/admin/llm-jobs" className="link">LLM accounting →</Link></p>
143+ </section>
144+
145+ <section className="mt-8">
146+ <p className="eyebrow mb-2">Recent runs</p>
147+ <DataTable compact scroll caption="Recent runs">
148+ <thead>
149+ <tr>
150+ <Th>Connector</Th>
151+ <Th>Started</Th>
152+ <Th>Status</Th>
153+ <Th num>Duration</Th>
154+ <Th num>Fetched</Th>
155+ <Th num>Changed</Th>
156+ <Th num>Failed</Th>
157+ <Th num>Claims</Th>
158+ <Th num>Events</Th>
159+ <Th>Error</Th>
160+ </tr>
161+ </thead>
162+ <tbody>
163+ {!o.recent_runs?.length && <EmptyRow cols={10}>No runs yet.</EmptyRow>}
164+ {o.recent_runs?.map((r) => (
165+ <tr key={r.id}>
166+ <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs text-ink hover:text-accent">{r.connector_name}</Link></Td>
167+ <Td className="text-xs"><LiveAgo at={r.started_at} /></Td>
168+ <Td><StatusChip value={r.status} /></Td>
169+ <Td num className="tnum text-xs">{num(r.duration_ms) === null ? '—' : fmtDuration(Math.round((num(r.duration_ms) ?? 0) / 1000))}</Td>
170+ <Td num className="tnum text-xs">{fmtInt(r.docs_fetched)}</Td>
171+ <Td num className="tnum text-xs">{fmtInt(r.docs_changed)}</Td>
172+ <Td num className={`tnum text-xs ${num(r.docs_failed) ? 'text-danger' : ''}`}>{fmtInt(r.docs_failed)}</Td>
173+ <Td num className="tnum text-xs">{fmtInt(r.claims_written)}</Td>
174+ <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
175+ <Td className="text-xs text-ink-2"><Trunc text={r.error} max={60} /></Td>
176+ </tr>
177+ ))}
178+ </tbody>
179+ </DataTable>
180+ <p className="mt-2 text-xs"><Link href="/admin/runs" className="link">All runs →</Link></p>
181+ </section>
182+ </>
183+ );
184+}
added apps/web/src/app/admin/page.tsx +23 −0
@@ -0,0 +1,23 @@
1+import type { Metadata } from 'next';
2+import { redirect } from 'next/navigation';
3+import { LoginForm } from '@/components/admin/login-form';
4+import { getAdminToken } from '@/lib/admin/session';
5+
6+export const metadata: Metadata = { title: 'Sign in', robots: { index: false, follow: false } };
7+export const dynamic = 'force-dynamic';
8+
9+/** `/admin`: login form, or straight to the overview when a session cookie is present (the overview validates it). */
10+export default async function AdminLogin({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
11+ const sp = await searchParams;
12+ const token = await getAdminToken();
13+ if (token && !sp.expired && !sp.signed_out) redirect('/admin/overview');
14+ const hint = sp.expired ? 'Your session expired or the token was rejected — sign in again.' : sp.signed_out ? 'Signed out.' : null;
15+ return (
16+ <div className="py-16 md:py-24">
17+ <p className="eyebrow">AI Atlas</p>
18+ <h1 className="display mt-2 text-3xl md:text-4xl">Admin console</h1>
19+ <p className="mt-3 max-w-md text-sm text-ink-2">Connectors, runs, documents, jobs, review queue and infrastructure. Restricted to operators.</p>
20+ <LoginForm hint={hint} />
21+ </div>
22+ );
23+}
added apps/web/src/app/admin/review/page.tsx +182 −0
@@ -0,0 +1,182 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ActionButton, AdminFilters, AdminTitle, JsonPre, KindChip, Mono, Notice, StatusChip, Trunc } from '@/components/admin/ui';
4+import { EntityBadge, TierBadge } from '@/components/ui/badges';
5+import { LiveAgo } from '@/components/ui/live';
6+import { Pagination, withParams } from '@/components/ui/pagination';
7+import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8+import { keepConflictSideAction, reviewAction } from '@/lib/admin/actions';
9+import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
10+import type { ReviewItem } from '@/lib/admin/types';
11+import { fmtDateTime, fmtInt, fmtValue, num } from '@/lib/format';
12+import { propertyLabel, routes } from '@/lib/site';
13+
14+export const metadata: Metadata = { title: 'Review queue', robots: { index: false, follow: false } };
15+export const dynamic = 'force-dynamic';
16+
17+const LIMIT = 25;
18+const STATUSES = ['pending', 'approved', 'rejected', 'edited', 'all'];
19+const KINDS = ['conflict', 'merge_candidate', 'parser_breakage', 'blocked_source'];
20+
21+function host(url: unknown): string {
22+ if (typeof url !== 'string') return '—';
23+ try {
24+ return new URL(url).hostname.replace(/^www\./, '');
25+ } catch {
26+ return url;
27+ }
28+}
29+
30+/** Conflict payload `{ property, current, claimed, current_source, claimed_source, tier }` rendered as two columns, each with a "Keep" button. */
31+function ConflictView({ item, ret }: { item: ReviewItem; ret: string }) {
32+ const p = item.payload ?? {};
33+ const property = typeof p.property === 'string' ? p.property : null;
34+ const entity = item.entities?.[0];
35+ const sides = [
36+ { key: 'current', title: 'Current value', value: p.current, source: p.current_source, tier: p.current_tier ?? null },
37+ { key: 'claimed', title: 'Claimed by new source', value: p.claimed, source: p.claimed_source, tier: p.tier ?? p.claimed_tier ?? null },
38+ ];
39+ return (
40+ <div className="mt-2 grid gap-3 md:grid-cols-2">
41+ {sides.map((s) => (
42+ <div key={s.key} className="border border-rule p-3">
43+ <p className="eyebrow">{s.title}</p>
44+ <p className="tnum mt-1 break-words text-sm font-medium text-ink">{fmtValue(s.value, property ?? undefined)}</p>
45+ <p className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-ink-3">
46+ {typeof s.source === 'string' ? (
47+ <a href={s.source} target="_blank" rel="noopener noreferrer" className="text-ink-2 hover:text-accent" title={s.source}>
48+ {host(s.source)}
49+ </a>
50+ ) : (
51+ <span>source unknown</span>
52+ )}
53+ {num(s.tier) !== null && <TierBadge tier={num(s.tier)} />}
54+ </p>
55+ {item.status === 'pending' && property && entity && (
56+ <form action={keepConflictSideAction} className="mt-2">
57+ <input type="hidden" name="id" value={item.id} />
58+ <input type="hidden" name="slug" value={entity.slug} />
59+ <input type="hidden" name="property" value={property} />
60+ <input type="hidden" name="value" value={JSON.stringify(s.value ?? null)} />
61+ <input type="hidden" name="source_url" value={typeof s.source === 'string' ? s.source : ''} />
62+ <input type="hidden" name="return" value={ret} />
63+ <ActionButton tone="positive" title="Promote this claim to current and supersede the other">Keep this</ActionButton>
64+ </form>
65+ )}
66+ </div>
67+ ))}
68+ </div>
69+ );
70+}
71+
72+export default async function AdminReviewPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
73+ await requireAdmin();
74+ const sp = await searchParams;
75+ const current: Record<string, string | undefined> = { status: sp.status ?? 'pending' };
76+ if (sp.kind) current.kind = sp.kind;
77+ if (sp.offset) current.offset = sp.offset;
78+ const offset = Math.max(0, Number(current.offset) || 0);
79+ const href = (patch: Record<string, string | number | undefined | null>) => withParams('/admin/review', current, patch);
80+ const ret = href({});
81+ const res = await load(adminApi.review({ status: current.status, kind: current.kind, limit: LIMIT, offset }));
82+ const byKind = res.ok ? (res.data.by_kind ?? []).filter((k) => k.status === (current.status === 'all' ? k.status : current.status)) : [];
83+ return (
84+ <>
85+ <AdminTitle title="Review queue" count={res.ok ? fmtInt(res.data.total) : undefined} lede="Conflicts keep both claims until an operator picks one; merges move aliases, identifiers, claims, relations and events into the target." />
86+ <Notice notice={sp.notice} level={sp.level} />
87+ {byKind.length > 0 && (
88+ <ul className="mb-4 flex flex-wrap gap-1.5 text-xs">
89+ {byKind.map((k) => (
90+ <li key={`${k.kind}-${k.status}`}>
91+ <Link href={href({ kind: current.kind === k.kind ? undefined : k.kind, offset: undefined })} className={`inline-flex h-7 items-center gap-1.5 border px-2 ${current.kind === k.kind ? 'border-accent text-accent' : 'border-rule text-ink-2 hover:border-rule-strong'}`}>
92+ <span className="mono">{k.kind}</span> <span className="tnum">{fmtInt(k.n)}</span> {current.status === 'all' && <StatusChip value={k.status} />}
93+ </Link>
94+ </li>
95+ ))}
96+ </ul>
97+ )}
98+ <AdminFilters
99+ action="/admin/review"
100+ className="mb-4"
101+ fields={[
102+ { kind: 'select', name: 'status', label: 'Status', value: current.status, any: 'pending', options: STATUSES.map((s) => ({ value: s, label: s })) },
103+ { kind: 'select', name: 'kind', label: 'Kind', value: current.kind, options: KINDS.map((k) => ({ value: k, label: k })) },
104+ ]}
105+ />
106+ {!res.ok ? (
107+ <Unavailable what="Review queue" reason={res.error} />
108+ ) : res.data.items.length === 0 ? (
109+ <EmptyState title="Nothing to review" />
110+ ) : (
111+ <>
112+ <ul className="divide-y divide-rule border-y border-rule">
113+ {res.data.items.map((item) => {
114+ const p = item.payload ?? {};
115+ const property = typeof p.property === 'string' ? p.property : null;
116+ return (
117+ <li key={item.id} className="py-3">
118+ <div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
119+ <div className="min-w-0 flex-1">
120+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
121+ <KindChip value={item.kind} />
122+ <StatusChip value={item.status} />
123+ {property && <span className="text-xs text-ink-2">{propertyLabel(property)}</span>}
124+ <span className="text-xs text-ink-3" title={fmtDateTime(item.created_at)}>
125+ <LiveAgo at={item.created_at} />
126+ </span>
127+ <Mono>{item.id}</Mono>
128+ </div>
129+ {item.reason && <p className="mt-1 text-sm text-ink"><Trunc text={item.reason} max={200} /></p>}
130+ {item.entities && item.entities.length > 0 && (
131+ <ul className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-sm">
132+ {item.entities.map((e) => (
133+ <li key={e.id} className="flex items-center gap-1.5">
134+ <EntityBadge type={e.entity_type} small />
135+ <Link href={routes.entity(e)} className="text-ink hover:text-accent hover:underline">{e.name}</Link>
136+ <Mono title={e.id}>{e.slug}</Mono>
137+ {e.status !== 'active' && <StatusChip value={e.status} />}
138+ </li>
139+ ))}
140+ </ul>
141+ )}
142+ {item.kind === 'conflict' && <ConflictView item={item} ret={ret} />}
143+ {item.kind === 'merge_candidate' && item.entities && item.entities.length > 1 && item.status === 'pending' && (
144+ <p className="mt-1.5 text-xs text-ink-3">
145+ Approve merges <Mono>{item.entity_ids[0]}</Mono> into <Mono>{item.entity_ids[1]}</Mono> (first two ids); pick directions explicitly under <Link href={`/admin/entities/duplicates?type=${encodeURIComponent(item.entities[0]?.entity_type ?? '')}`} className="link">Duplicates</Link> when the order is wrong.
146+ </p>
147+ )}
148+ <details className="mt-2">
149+ <summary className="cursor-pointer text-xs text-ink-3 hover:text-ink">Payload{item.resolution ? ' · resolution' : ''}</summary>
150+ <div className="mt-1 grid gap-2 md:grid-cols-2">
151+ <JsonPre value={item.payload} maxHeight="16rem" />
152+ {item.resolution && <JsonPre value={item.resolution} maxHeight="16rem" />}
153+ </div>
154+ </details>
155+ </div>
156+ {item.status === 'pending' && (
157+ <div className="flex shrink-0 items-center gap-1.5">
158+ <form action={reviewAction}>
159+ <input type="hidden" name="id" value={item.id} />
160+ <input type="hidden" name="action" value="approve" />
161+ <input type="hidden" name="return" value={ret} />
162+ <ActionButton tone="positive" title={item.kind === 'conflict' ? 'Mark approved without promoting a claim (use "Keep this" to pick a side)' : 'Approve'}>Approve</ActionButton>
163+ </form>
164+ <form action={reviewAction}>
165+ <input type="hidden" name="id" value={item.id} />
166+ <input type="hidden" name="action" value="reject" />
167+ <input type="hidden" name="return" value={ret} />
168+ <ActionButton tone="danger">Reject</ActionButton>
169+ </form>
170+ </div>
171+ )}
172+ </div>
173+ </li>
174+ );
175+ })}
176+ </ul>
177+ <Pagination total={res.data.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
178+ </>
179+ )}
180+ </>
181+ );
182+}
added apps/web/src/app/admin/runs/page.tsx +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/admin/snapshots/[id]/page.tsx +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/benchmarks/[slug]/page.tsx +213 −0
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/benchmarks/page.tsx +45 −12
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/companies/[slug]/opengraph-image.tsx +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/companies/[slug]/page.tsx +5 −4
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/compare/page.tsx +73 −66
Large diff (158 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/datasets/page.tsx +73 −3
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/developers/page.tsx +135 −26
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/diff/page.tsx +193 −0
Large diff (193 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/explore/[type]/[slug]/page.tsx +5 −4
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/frameworks/page.tsx +63 −3
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/graph/[slug]/page.tsx +163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/hardware/fit/page.tsx +166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/hardware/page.tsx +29 −2
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon-192.png/render.tsx +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon-192.png/route.tsx +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon-512.png/route.tsx +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/manifest.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/models/(list)/page.tsx +12 −4
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/models/[slug]/opengraph-image.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/models/[slug]/page.tsx +5 −4
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/not-found.tsx +25 −11
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/papers/page.tsx +92 −8
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/prices/loading.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/prices/page.tsx +228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/providers/page.tsx +10 −2
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/robots.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/timeline/loading.tsx +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/timeline/page.tsx +136 −49
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/tools/page.tsx +63 −3
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/login-form.tsx +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/nav-select.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/shell.tsx +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/ui.tsx +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/benchmarks/leaderboard.tsx +191 −0
Large diff (191 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/brand/og.tsx +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/charts/charts.tsx +26 −8
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/compare/compare-button.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/compare/compare-matrix.tsx +312 −0
Large diff (312 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/compare/compare-picker.tsx +218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/compare/compare-store.ts +111 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/compare/compare-tray-bar.tsx +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/entity/asof-picker.tsx +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/entity/entity-page.tsx +37 −8
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/entity/history.tsx +170 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/graph/graph-explorer.tsx +167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/hardware/fit-form.tsx +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/listing/generic-listing.tsx +3 −3
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/listing/typed-listing.tsx +133 −0
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/prices/movers.tsx +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/prices/scale-toggle.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/timeline/chip-row.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/actions.ts +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/admin-api.ts +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/session.ts +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin/types.ts +260 −0
Large diff (260 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/site.ts +39 −1
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/types.ts +48 −5
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
modified docs/FRONTEND.md +88 −22
Large diff (121 lines) is not loaded by default to keep the page fast.Load diff
modified pnpm-lock.yaml +39 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme