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 12 "dependencies": {
13 13 "d3-array": "^3.2.4",
14 + "d3-force": "^3.0.0",
14 15 "d3-scale": "^4.0.2",
15 16 "d3-shape": "^3.2.0",
16 17 "geist": "^1.5.1",
@@ -23,6 +24,7 @@
23 24 "devDependencies": {
24 25 "@tailwindcss/postcss": "^4",
25 26 "@types/d3-array": "^3.2.1",
27 + "@types/d3-force": "^3.0.10",
26 28 "@types/d3-scale": "^4.0.9",
27 29 "@types/d3-shape": "^3.1.7",
28 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 1 import type { Metadata } from 'next';
2 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 4 import { entityMetadata, loadEntity } from '@/components/entity/load';
5 5 import { api, safe } from '@/lib/api';
6 6 import { PATH_TYPES, routes } from '@/lib/site';
@@ -9,7 +9,7 @@ import { PATH_TYPES, routes } from '@/lib/site';
9 9 * Generic entity page for /providers, /benchmarks, /hardware, /papers, /frameworks, /datasets, /tools, /repositories.
10 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 14 export async function generateMetadata({ params }: Params): Promise<Metadata> {
15 15 const { type, slug } = await params;
@@ -17,12 +17,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
17 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 21 const { type, slug } = await params;
22 + const sp = await searchParams;
22 23 if (!PATH_TYPES[type]) notFound();
23 24 const d = await loadEntity(type, slug);
24 25 const canonical = routes.entity(d);
25 26 if (canonical !== `/${type}/${encodeURIComponent(slug)}`) permanentRedirect(canonical);
26 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