SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%

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 12 days ago (Sep 11, 2026) parent 08b1775

77 changed files +6,501 −253

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
@@ -0,0 +1,79 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminFilters, AdminTitle, 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 { Unavailable } from '@/components/ui/unavailable';
7 +import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';
8 +import { fmtDateTime, fmtDuration, fmtInt, num } from '@/lib/format';
9 +
10 +export const metadata: Metadata = { title: 'Runs', robots: { index: false, follow: false } };
11 +export const dynamic = 'force-dynamic';
12 +
13 +export default async function AdminRunsPage({ 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 [runs, connectors] = await Promise.all([load(adminApi.runs({ 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="Runs" count={runs.ok ? fmtInt(runs.data.total) : undefined} />
22 + <AdminFilters
23 + action="/admin/runs"
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 + {!runs.ok ? (
31 + <Unavailable what="Runs" reason={runs.error} />
32 + ) : (
33 + <DataTable compact scroll caption="Connector runs">
34 + <thead>
35 + <tr>
36 + <Th>Connector</Th>
37 + <Th>Started</Th>
38 + <Th>Status</Th>
39 + <Th num>Duration</Th>
40 + <Th num>Disc.</Th>
41 + <Th num>Fetched</Th>
42 + <Th num>Changed</Th>
43 + <Th num>Unch.</Th>
44 + <Th num>Failed</Th>
45 + <Th num>Ent. +/~</Th>
46 + <Th num>Claims</Th>
47 + <Th num>Rel.</Th>
48 + <Th num>Events</Th>
49 + <Th>Error</Th>
50 + <Th>Run id</Th>
51 + </tr>
52 + </thead>
53 + <tbody>
54 + {runs.data.items.length === 0 && <EmptyRow cols={15}>No runs match.</EmptyRow>}
55 + {runs.data.items.map((r) => (
56 + <tr key={r.id}>
57 + <Td><Link href={`/admin/runs?connector=${encodeURIComponent(r.connector_name)}`} className="mono text-xs font-medium text-ink hover:text-accent">{r.connector_name}</Link></Td>
58 + <Td className="text-xs" title={fmtDateTime(r.started_at)}><LiveAgo at={r.started_at} /></Td>
59 + <Td><StatusChip value={r.status} /></Td>
60 + <Td num className="tnum text-xs">{num(r.duration_ms) === null ? '—' : fmtDuration(Math.round((num(r.duration_ms) ?? 0) / 1000))}</Td>
61 + <Td num className="tnum text-xs">{fmtInt(r.docs_discovered)}</Td>
62 + <Td num className="tnum text-xs">{fmtInt(r.docs_fetched)}</Td>
63 + <Td num className="tnum text-xs">{fmtInt(r.docs_changed)}</Td>
64 + <Td num className="tnum text-xs text-ink-3">{fmtInt(r.docs_unchanged)}</Td>
65 + <Td num className={`tnum text-xs ${num(r.docs_failed) ? 'text-danger' : ''}`}>{fmtInt(r.docs_failed)}</Td>
66 + <Td num className="tnum text-xs">{fmtInt(r.entities_created)} / {fmtInt(r.entities_updated)}</Td>
67 + <Td num className="tnum text-xs">{fmtInt(r.claims_written)}</Td>
68 + <Td num className="tnum text-xs">{fmtInt(r.relations_written)}</Td>
69 + <Td num className="tnum text-xs">{fmtInt(r.events_emitted)}</Td>
70 + <Td className="text-xs text-danger"><Trunc text={r.error} max={50} /></Td>
71 + <Td><Mono>{r.id}</Mono></Td>
72 + </tr>
73 + ))}
74 + </tbody>
75 + </DataTable>
76 + )}
77 + </>
78 + );
79 +}
added apps/web/src/app/admin/snapshots/[id]/page.tsx +123 −0
@@ -0,0 +1,123 @@
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 { 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, fmtDateTime, fmtInt } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +
11 +export const metadata: Metadata = { title: 'Snapshot', robots: { index: false, follow: false } };
12 +export const dynamic = 'force-dynamic';
13 +
14 +/** Render the API's `diff` as change rows when it is a map/array of changes, otherwise as JSON. */
15 +function Diff({ diff }: { diff: unknown }) {
16 + if (diff === null || diff === undefined) return <p className="text-xs text-ink-3">No diff (first snapshot or unchanged).</p>;
17 + const rows: { key: string; before: unknown; after: unknown }[] = [];
18 + if (Array.isArray(diff)) {
19 + for (const [i, it] of diff.entries()) {
20 + if (it && typeof it === 'object') {
21 + const o = it as Record<string, unknown>;
22 + rows.push({ key: String(o.key ?? o.path ?? o.property ?? o.field ?? i), before: o.old ?? o.before ?? o.from ?? o.old_value, after: o.new ?? o.after ?? o.to ?? o.new_value });
23 + }
24 + }
25 + } else if (typeof diff === 'object') {
26 + const o = diff as Record<string, unknown>;
27 + const looksLikeMap = Object.values(o).every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('old' in (v as object) || 'new' in (v as object) || 'before' in (v as object) || 'after' in (v as object)));
28 + if (looksLikeMap) {
29 + for (const [k, v] of Object.entries(o)) {
30 + const c = v as Record<string, unknown>;
31 + rows.push({ key: k, before: c.old ?? c.before, after: c.new ?? c.after });
32 + }
33 + }
34 + }
35 + if (!rows.length) return <JsonPre value={diff} />;
36 + const show = (v: unknown) => (v === undefined || v === null ? <span className="text-ink-3">—</span> : <span className="break-all">{typeof v === 'string' ? v : JSON.stringify(v)}</span>);
37 + return (
38 + <ul className="divide-y divide-rule border-y border-rule text-xs">
39 + {rows.map((r) => (
40 + <li key={r.key} className="grid gap-x-3 gap-y-0.5 py-1.5 md:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)]">
41 + <Mono className="text-ink">{r.key}</Mono>
42 + <span className="text-danger line-through decoration-danger/50">{show(r.before)}</span>
43 + <span className="text-positive">{show(r.after)}</span>
44 + </li>
45 + ))}
46 + </ul>
47 + );
48 +}
49 +
50 +export default async function AdminSnapshotPage({ params }: { params: Promise<{ id: string }> }) {
51 + await requireAdmin();
52 + const { id } = await params;
53 + const res = await load(adminApi.snapshot(id));
54 + if (!res.ok && res.error.startsWith('404')) notFound();
55 + if (!res.ok) {
56 + return (
57 + <>
58 + <AdminTitle title="Snapshot" />
59 + <Unavailable what="Snapshot" reason={res.error} />
60 + </>
61 + );
62 + }
63 + const s = res.data;
64 + return (
65 + <>
66 + <p className="mb-2 text-xs">
67 + <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>
68 + </p>
69 + <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>} />
70 + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">
71 + <div className="min-w-0 space-y-8">
72 + <section>
73 + <p className="eyebrow mb-2">Structured extraction</p>
74 + <JsonPre value={s.structured} maxHeight="28rem" />
75 + </section>
76 + <section>
77 + <p className="eyebrow mb-2">Diff vs previous snapshot</p>
78 + <Diff diff={s.diff} />
79 + </section>
80 + <section>
81 + <p className="eyebrow mb-2">
82 + Cleaned text excerpt {s.text_chars !== undefined && <span className="tnum text-ink-3">{fmtInt(s.text_chars)} chars{s.text_truncated ? ' · truncated at 20 kB' : ''}</span>}
83 + </p>
84 + {s.text ? (
85 + <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2">{s.text}</pre>
86 + ) : (
87 + <p className="text-xs text-ink-3">{s.text_error ? `Text unavailable: ${s.text_error}` : s.has_text === false ? 'No cleaned text for this snapshot (binary or JSON document).' : 'No text.'}</p>
88 + )}
89 + </section>
90 + </div>
91 + <aside className="min-w-0 space-y-6">
92 + <KeyValue
93 + dense
94 + rows={[
95 + { key: 'url', label: 'URL', value: <a href={s.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">{s.url}</a> },
96 + { key: 'final_url', label: 'Final URL', value: s.final_url && s.final_url !== s.url ? <span className="break-all text-xs">{s.final_url}</span> : undefined },
97 + { key: 'connector', label: 'Connector', value: <Mono>{s.connector_name ?? '—'}</Mono> },
98 + { key: 'doc_type', label: 'Type', raw: s.doc_type },
99 + { key: 'entity', label: 'Entity', value: s.entity_slug ? <Link href={routes.entity({ entity_type: s.entity_id?.split('_')[0] ?? 'model', slug: s.entity_slug })} className="link text-xs">{s.entity_name ?? s.entity_slug}</Link> : undefined },
100 + { key: 'http_status', label: 'HTTP', raw: s.http_status },
101 + { key: 'content_type', label: 'Content type', raw: s.content_type },
102 + { key: 'byte_size', label: 'Size', raw: fmtBytes(s.byte_size) },
103 + { key: 'changed', label: 'Changed', value: <Bool v={s.changed} /> },
104 + { key: 'processing_status', label: 'Processing', value: <StatusChip value={s.processing_status} /> },
105 + { key: 'parser_version', label: 'Parser', raw: s.parser_version },
106 + { key: 'connector_version', label: 'Connector version', raw: s.connector_version },
107 + { key: 'transport', label: 'Transport', raw: s.transport },
108 + { key: 'content_hash', label: 'Content hash', value: s.content_hash ? <Mono className="break-all">{s.content_hash}</Mono> : undefined },
109 + { key: 'run_id', label: 'Run', value: s.run_id ? <Mono>{s.run_id}</Mono> : undefined },
110 + { key: 'has_raw', label: 'Raw archived', value: <Bool v={s.has_raw} /> },
111 + ]}
112 + />
113 + {s.headers && Object.keys(s.headers).length > 0 && (
114 + <div>
115 + <p className="eyebrow mb-2">Response headers</p>
116 + <JsonPre value={s.headers} maxHeight="14rem" />
117 + </div>
118 + )}
119 + </aside>
120 + </div>
121 + </>
122 + );
123 +}
added apps/web/src/app/benchmarks/[slug]/page.tsx +213 −0
@@ -0,0 +1,213 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import type { Metadata } from 'next';
3 +import Link from 'next/link';
4 +import { permanentRedirect } from 'next/navigation';
5 +import { configChips, ConfigChips, HistoryChart, Leaderboard } from '@/components/benchmarks/leaderboard';
6 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
7 +import { RelationsBlock, SourcesTable, TimelineList } from '@/components/entity/blocks';
8 +import { HistoryPanel } from '@/components/entity/history';
9 +import { entityMetadata, loadEntity } from '@/components/entity/load';
10 +import { ViewBeacon } from '@/components/layout/view-beacon';
11 +import { EntityBadge, StatusBadge } from '@/components/ui/badges';
12 +import { QualityMark } from '@/components/ui/entity';
13 +import { KeyValue, type KVRow } from '@/components/ui/key-value';
14 +import { Container, Note } from '@/components/ui/section';
15 +import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs';
16 +import { Unavailable } from '@/components/ui/unavailable';
17 +import { api, safe } from '@/lib/api';
18 +import { fmtAgo, fmtDate, fmtInt, fmtScore } from '@/lib/format';
19 +import { routes, SITE_NAME, SITE_URL } from '@/lib/site';
20 +
21 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<Record<string, string | undefined>> };
22 +const LIMIT = 100;
23 +
24 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
25 + const { slug } = await params;
26 + const m = await entityMetadata('benchmarks', slug);
27 + if (!m.title || (m.robots as { index?: boolean } | undefined)?.index === false) return m;
28 + const name = String(m.title).split(' — ')[0];
29 + const title = `${name} leaderboard — results, configs and history`;
30 + return { ...m, title, openGraph: { ...(m.openGraph ?? {}), title: `${title} | ${SITE_NAME}` }, twitter: { ...(m.twitter ?? {}), title } };
31 +}
32 +
33 +/**
34 + * Dedicated benchmark page: definition, leaderboard (paged, config-filterable), per-model history chart,
35 + * timeline and sources. Static segment → shadows /[type]/[slug] for benchmarks only.
36 + */
37 +export default async function BenchmarkPage({ params, searchParams }: Params) {
38 + const { slug } = await params;
39 + const sp = await searchParams;
40 + const d = await loadEntity('benchmarks', slug);
41 + if (d.slug !== slug) permanentRedirect(routes.benchmark(d.slug, { config: sp.config, model: sp.model }));
42 + const canonical = routes.entity(d);
43 + const offset = Math.max(0, Number(sp.offset) || 0);
44 + const config = sp.config?.trim() || undefined;
45 + const model = sp.model?.trim() || undefined;
46 + // History tab (same mode as EntityPage): full claim history always, "as of" state only when requested.
47 + const asofRaw = sp.asof?.trim() || undefined;
48 + const asofValid = asofRaw && /^\d{4}-\d{2}-\d{2}$/.test(asofRaw) ? asofRaw : undefined;
49 + const property = sp.property?.trim() || undefined;
50 + const [page, history, claimHistory, asofPayload] = await Promise.all([
51 + safe(api.benchmarkResults(d.slug, { limit: LIMIT, offset, config })),
52 + model ? safe(api.benchmarkHistory(d.slug, model)) : Promise.resolve(null),
53 + safe(api.entityHistory(d.slug, property)),
54 + asofValid ? safe(api.entityAsOf(d.slug, asofValid)) : Promise.resolve(null),
55 + ]);
56 + const claims = claimHistory?.items ?? null;
57 + // Chips come from an unfiltered sample so the filter can be changed while one is active.
58 + const sample = config || offset ? await safe(api.benchmarkResults(d.slug, { limit: LIMIT })) : page;
59 + const chips = configChips(sample?.items ?? []);
60 + const a = d.attributes ?? {};
61 + const unit = typeof a.unit === 'string' ? a.unit : (page?.items[0]?.unit ?? null);
62 + const direction = page?.items[0] ? (page.items[0].higher_is_better === false ? 'Lower is better' : 'Higher is better') : null;
63 + const link = (k: string) => (typeof a[k] === 'string' && /^https?:\/\//.test(a[k] as string) ? (a[k] as string) : null);
64 + const site = link('website') ?? link('official_url');
65 + const defRows: KVRow[] = [
66 + { key: 'category', raw: a.category },
67 + { key: 'task', raw: a.task },
68 + { key: 'metric', raw: a.metric, value: typeof a.metric === 'string' ? <span>{a.metric}{unit && <span className="text-ink-3"> · {unit}</span>}</span> : undefined },
69 + ...(direction ? [{ key: 'direction', label: 'Direction', value: <span>{direction}</span> }] : []),
70 + { key: 'creator', raw: a.creator },
71 + { key: 'paper', raw: a.paper },
72 + { key: 'paper_url', raw: a.paper_url },
73 + { key: 'methodology', raw: a.methodology, value: link('methodology') ? <a href={link('methodology') as string} className="link break-all" target="_blank" rel="noopener noreferrer">{(link('methodology') as string).replace(/^https?:\/\/(www\.)?/, '')}</a> : undefined },
74 + { key: 'known_limitations', raw: a.known_limitations },
75 + { key: 'website', raw: a.website, value: site ? <a href={site} className="link break-all" target="_blank" rel="noopener noreferrer">{site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '')}</a> : undefined },
76 + { key: 'official_url', raw: link('website') ? undefined : a.official_url },
77 + ];
78 + const tabs: TabDef[] = [
79 + { id: 'leaderboard', label: 'Leaderboard', count: page?.total ?? d.results?.length ?? undefined },
80 + { id: 'definition', label: 'Definition' },
81 + { id: 'relations', label: 'Relations', count: d.relations?.reduce((n, g) => n + g.items.length, 0) || undefined },
82 + { id: 'history', label: 'History', count: property ? undefined : claims?.length || undefined },
83 + { id: 'timeline', label: 'Timeline', count: d.timeline?.length || undefined },
84 + { id: 'sources', label: 'Sources', count: d.sources?.length || undefined },
85 + ];
86 + const ld = { '@context': 'https://schema.org', '@type': 'Dataset', name: d.name, url: `${SITE_URL}${canonical}`, description: d.description ?? (typeof a.task === 'string' ? a.task : undefined), alternateName: d.aliases?.length ? d.aliases : undefined, creator: typeof a.creator === 'string' ? { '@type': 'Organization', name: a.creator } : d.organization ? { '@type': 'Organization', name: d.organization.name } : undefined, sameAs: site ? [site] : undefined, measurementTechnique: typeof a.metric === 'string' ? a.metric : undefined };
87 + const href = (o: number) => routes.benchmark(d.slug, { config, model }) + (o ? `${config || model ? '&' : '?'}offset=${o}` : '');
88 +
89 + return (
90 + <Container wide>
91 + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }} />
92 + <ViewBeacon path={canonical} />
93 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
94 + <ol className="flex flex-wrap items-center gap-1.5">
95 + <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li>
96 + <li aria-hidden>/</li>
97 + <li><Link href={routes.benchmarks()} className="hover:text-ink">Benchmarks</Link></li>
98 + {typeof a.category === 'string' && (
99 + <>
100 + <li aria-hidden>/</li>
101 + <li><Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-ink">{a.category}</Link></li>
102 + </>
103 + )}
104 + <li aria-hidden>/</li>
105 + <li className="text-ink-2">{d.name}</li>
106 + </ol>
107 + </nav>
108 +
109 + <header className="pb-6 pt-4 md:pb-8 md:pt-5">
110 + <div className="flex flex-wrap items-center gap-2">
111 + <EntityBadge type={d.entity_type} />
112 + <StatusBadge status={d.status} />
113 + {typeof a.category === 'string' && <span className="text-xs text-ink-3">category · {a.category}</span>}
114 + </div>
115 + <div className="mt-3 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
116 + <div className="min-w-0">
117 + <h1 className="display text-[30px] md:text-[44px]">{d.name}</h1>
118 + <p className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[15px] text-ink-2">
119 + {typeof a.creator === 'string' && <span className="font-medium text-ink">{a.creator}</span>}
120 + {d.organization && (
121 + <Link href={routes.entity({ entity_type: 'company', slug: d.organization.slug })} className="font-medium text-ink hover:text-accent">
122 + {d.organization.name}
123 + </Link>
124 + )}
125 + {site && (
126 + <a href={site} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-ink-3 hover:text-accent">
127 + {site.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '').slice(0, 48)} <ExternalLink className="size-3.5" aria-hidden />
128 + </a>
129 + )}
130 + </p>
131 + {(d.description || typeof a.task === 'string') && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description ?? (a.task as string)}</p>}
132 + </div>
133 + <div className="shrink-0 text-xs text-ink-3 lg:text-right">
134 + <QualityMark q={d.quality?.score} label />
135 + <p className="mt-1" title={d.updated_at}>Updated {fmtAgo(d.updated_at)} · first seen {fmtDate(d.first_seen_at)}</p>
136 + <p className="mono mt-0.5 text-[11px]">{d.id}</p>
137 + </div>
138 + </div>
139 + <dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 border-y border-rule py-3 sm:grid-cols-4">
140 + <div><dt className="eyebrow">Metric</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{typeof a.metric === 'string' ? a.metric : '—'}{unit && <span className="text-ink-3"> · {unit}</span>}</dd></div>
141 + <div><dt className="eyebrow">Direction</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{direction ?? '—'}</dd></div>
142 + <div><dt className="eyebrow">Results</dt><dd className="tnum mt-0.5 text-[15px] font-medium text-ink">{sample ? fmtInt(sample.total) : '—'}{config && page && <span className="text-xs text-ink-3"> · {fmtInt(page.total)} filtered</span>}</dd></div>
143 + <div><dt className="eyebrow">Leader</dt><dd className="mt-0.5 truncate text-[15px] font-medium text-ink">{sample?.items[0] ? <Link href={routes.entity(sample.items[0].model)} className="hover:text-accent">{sample.items[0].model.name} <span className="tnum text-ink-2">{fmtScore(sample.items[0].score)}{unit === '%' ? '%' : ''}</span></Link> : '—'}</dd></div>
144 + </dl>
145 + </header>
146 +
147 + <Tabs tabs={tabs} className="pb-10">
148 + <TabPanel id="leaderboard">
149 + <div className="space-y-6">
150 + {model && (
151 + <section>
152 + {history ? <HistoryChart items={history.items} model={model} unit={unit} /> : <Unavailable what="Result history" compact />}
153 + <p className="mt-2 text-xs">
154 + <Link href={routes.benchmark(d.slug, { config })} className="link">Back to the full leaderboard</Link>
155 + </p>
156 + </section>
157 + )}
158 + <section className="space-y-3">
159 + <div className="flex flex-wrap items-center justify-between gap-3">
160 + <p className="eyebrow">
161 + Leaderboard {page && <span className="tnum text-ink-3">{fmtInt(page.total)} current {page.total === 1 ? 'result' : 'results'}{config ? ` · config contains “${config}”` : ''}</span>}
162 + </p>
163 + <p className="text-xs text-ink-3">
164 + Select models with <span className="mono">+</span>, then open <Link href={routes.compare()} className="link">Compare</Link>.
165 + </p>
166 + </div>
167 + <ConfigChips slug={d.slug} chips={chips} active={config} model={model} />
168 + {!page ? <Unavailable what="Leaderboard" /> : <Leaderboard slug={d.slug} results={page.items} total={page.total} limit={LIMIT} offset={offset} config={config} model={model} unit={unit} makeHref={href} />}
169 + {chips.length > 0 && <Note>The config filter matches a value inside each result's configuration (server-side, `config=` on the API). Chips are the values shared by several rows on the first page; per-model identifiers are not offered.</Note>}
170 + </section>
171 + </div>
172 + </TabPanel>
173 + <TabPanel id="definition">
174 + <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_22rem]">
175 + <section>
176 + <p className="eyebrow mb-2">Definition</p>
177 + <KeyValue rows={defRows} provenance={d.provenance} />
178 + <Note className="mt-3">
179 + Each value shows its source, tier and observation time. Missing rows mean no source stated them. <Link href="/methodology#benchmarks" className="link">How results are recorded →</Link>
180 + </Note>
181 + </section>
182 + <aside className="space-y-8">
183 + {d.aliases?.length > 0 && (
184 + <section>
185 + <p className="eyebrow mb-2">Also known as</p>
186 + <p className="text-sm text-ink-2">{d.aliases.join(', ')}</p>
187 + </section>
188 + )}
189 + <section>
190 + <p className="eyebrow mb-2">Identity</p>
191 + <p className="mono break-all text-[11px] text-ink-3">slug {d.slug}</p>
192 + <p className="mono break-all text-[11px] text-ink-3">{d.id}</p>
193 + </section>
194 + </aside>
195 + </div>
196 + </TabPanel>
197 + <TabPanel id="relations">
198 + <RelationsBlock relations={d.relations ?? []} />
199 + </TabPanel>
200 + <TabPanel id="history">
201 + <HistoryPanel d={d} asof={asofRaw} asofPayload={asofPayload} claims={claims} property={property} />
202 + </TabPanel>
203 + <TabPanel id="timeline">
204 + <TimelineList events={d.timeline ?? []} slug={d.slug} />
205 + </TabPanel>
206 + <TabPanel id="sources">
207 + <SourcesTable sources={d.sources ?? []} />
208 + </TabPanel>
209 + </Tabs>
210 + <CompareTrayBar />
211 + </Container>
212 + );
213 +}
modified apps/web/src/app/benchmarks/page.tsx +45 −12
@@ -1,23 +1,50 @@
1 1 import type { Metadata } from 'next';
2 +import Link from 'next/link';
2 3 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
3 4 import { EntityLink } from '@/components/ui/entity';
4 5 import { Container, Note, PageHeader } from '@/components/ui/section';
5 import { Unavailable } from '@/components/ui/unavailable';
6 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
6 7 import { api, safe } from '@/lib/api';
7 import { fmtInt, fmtScore, num } from '@/lib/format';
8 +import { cn } from '@/lib/cn';
9 +import { fmtAgo, fmtInt, fmtScore, num } from '@/lib/format';
10 +import { routes } from '@/lib/site';
8 11
9 12 export const metadata: Metadata = { title: 'AI benchmarks & leaderboards', description: 'Benchmarks with their published results, evaluation configs and current leaders — never compared blindly across configs.', alternates: { canonical: '/benchmarks' } };
10 13 export const revalidate = 300;
11 14
12 export default async function BenchmarksPage() {
15 +export default async function BenchmarksPage({ searchParams }: { searchParams: Promise<{ category?: string }> }) {
16 + const { category } = await searchParams;
13 17 const res = await safe(api.benchmarks());
14 const items = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0));
18 + const all = (res?.items ?? []).slice().sort((a, b) => (num(b.result_count) ?? 0) - (num(a.result_count) ?? 0));
19 + const cats = new Map<string, number>();
20 + for (const b of all) {
21 + const c = typeof b.attributes?.category === 'string' ? b.attributes.category : null;
22 + if (c) cats.set(c, (cats.get(c) ?? 0) + 1);
23 + }
24 + const catList = [...cats.entries()].sort((x, y) => y[1] - x[1] || x[0].localeCompare(y[0]));
25 + const items = category ? all.filter((b) => b.attributes?.category === category) : all;
26 + const chip = (active: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
15 27 return (
16 28 <Container>
17 <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)} benchmarks</p> : undefined} />
29 + <PageHeader eyebrow="Benchmarks" title="Benchmarks" lede="Evaluation suites and the results published for them. Each result carries its configuration; leaders are shown per benchmark, not as a composite." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(items.length)}{category ? ` of ${fmtInt(all.length)}` : ''} benchmarks</p> : undefined}>
30 + {catList.length > 0 && (
31 + <nav aria-label="Category" className="no-scrollbar -mx-4 mt-6 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
32 + <Link href={routes.benchmarks()} className={chip(!category)} aria-current={!category ? 'true' : undefined}>
33 + All <span className="tnum opacity-70">{fmtInt(all.length)}</span>
34 + </Link>
35 + {catList.map(([c, n]) => (
36 + <Link key={c} href={`/benchmarks?category=${encodeURIComponent(c)}`} className={chip(category === c)} aria-current={category === c ? 'true' : undefined}>
37 + {c} <span className="tnum opacity-70">{fmtInt(n)}</span>
38 + </Link>
39 + ))}
40 + </nav>
41 + )}
42 + </PageHeader>
18 43 <div className="pb-16">
19 44 {!res ? (
20 45 <Unavailable what="Benchmarks" />
46 + ) : items.length === 0 ? (
47 + <EmptyState title={category ? `No benchmark in “${category}”` : 'No benchmarks recorded yet'}>{category && <Link href={routes.benchmarks()} className="link">Show all benchmarks</Link>}</EmptyState>
21 48 ) : (
22 49 <>
23 50 <DataTable caption="Benchmarks">
@@ -29,37 +56,43 @@ export default async function BenchmarksPage() {
29 56 <Th num>Results</Th>
30 57 <Th num>Models</Th>
31 58 <Th>Current leader</Th>
59 + <Th>Updated</Th>
32 60 </tr>
33 61 </thead>
34 62 <tbody>
35 {items.length === 0 && <EmptyRow cols={6}>No benchmarks recorded yet.</EmptyRow>}
63 + {items.length === 0 && <EmptyRow cols={7}>No benchmarks recorded yet.</EmptyRow>}
36 64 {items.map((b) => {
37 65 const a = b.attributes ?? {};
38 66 return (
39 67 <tr key={b.id}>
40 68 <Td primary>
41 <EntityLink e={b} />
42 {b.description && <span className="block max-w-md truncate text-xs text-ink-3">{b.description}</span>}
69 + <Link href={routes.benchmark(b.slug)} className="text-ink hover:text-accent hover:underline">{b.name}</Link>
70 + {(b.description || typeof a.task === 'string') && <span className="block max-w-md truncate text-xs text-ink-3">{b.description ?? (a.task as string)}</span>}
71 + </Td>
72 + <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? <Link href={`/benchmarks?category=${encodeURIComponent(a.category)}`} className="hover:text-accent">{a.category}</Link> : <span className="text-ink-3">—</span>}</Td>
73 + <Td label="Metric" className="text-ink-2">
74 + {typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}
75 + {typeof a.unit === 'string' && a.unit && <span className="text-ink-3"> ({a.unit})</span>}
43 76 </Td>
44 <Td label="Category" className="text-ink-2">{typeof a.category === 'string' ? a.category : <span className="text-ink-3">—</span>}</Td>
45 <Td label="Metric" className="text-ink-2">{typeof a.metric === 'string' ? a.metric : <span className="text-ink-3">—</span>}</Td>
46 77 <Td num label="Results" className="tnum">{fmtInt(b.result_count)}</Td>
47 78 <Td num label="Models" className="tnum text-ink-2">{fmtInt(b.model_count)}</Td>
48 79 <Td label="Current leader">
49 80 {b.top ? (
50 81 <span>
51 <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}</span>
82 + <EntityLink e={b.top.model} className="font-medium" /> <span className="tnum text-ink-2">{fmtScore(b.top.score)}{a.unit === '%' ? '%' : ''}</span>
83 + {b.top.model.organization && <span className="block text-xs text-ink-3">{b.top.model.organization.name}</span>}
52 84 </span>
53 85 ) : (
54 86 <span className="text-ink-3">—</span>
55 87 )}
56 88 </Td>
89 + <Td label="Updated" className="text-ink-2" title={b.updated_at}>{fmtAgo(b.updated_at)}</Td>
57 90 </tr>
58 91 );
59 92 })}
60 93 </tbody>
61 94 </DataTable>
62 <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open the benchmark to see them.</Note>
95 + <Note className="mt-3">Leader = best current result under the benchmark's default direction (higher or lower is better). Configs differ; open a benchmark for its leaderboard, config filter and per-model history.</Note>
63 96 </>
64 97 )}
65 98 </div>
added apps/web/src/app/companies/[slug]/opengraph-image.tsx +44 −0
@@ -0,0 +1,44 @@
1 +import { ImageResponse } from 'next/og';
2 +import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og';
3 +import { api, safe } from '@/lib/api';
4 +import { fmtInt, num } from '@/lib/format';
5 +import { routes, SITE_NAME, typeLabel } from '@/lib/site';
6 +import type { EntityDetail } from '@/lib/types';
7 +
8 +export const runtime = 'nodejs';
9 +export const alt = `Organization on ${SITE_NAME}`;
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +function companyFacts(d: EntityDetail): [string, string][] {
14 + const a = d.attributes ?? {};
15 + const out: [string, string][] = [];
16 + if (typeof a.country === 'string') out.push(['Country', a.country]);
17 + if (a.founded) out.push(['Founded', String(a.founded).slice(0, 4)]);
18 + const models = num(d.model_count) ?? num(d.models?.total) ?? (d.models?.items.length || null);
19 + if (models !== null) out.push(['Models', fmtInt(models)]);
20 + const papers = num(d.paper_count) ?? (d.papers?.length || null);
21 + if (papers !== null) out.push(['Papers', fmtInt(papers)]);
22 + if (typeof a.headquarters === 'string') out.push(['HQ', a.headquarters.slice(0, 22)]);
23 + return out.slice(0, 3);
24 +}
25 +
26 +/** Per-organization Open Graph image: brand, type eyebrow, name, three key facts, canonical URL. */
27 +export default async function CompanyOgImage({ params }: { params: Promise<{ slug: string }> }) {
28 + const { slug } = await params;
29 + const d = await safe(api.entityOfType('companies', slug));
30 + if (!d) return new ImageResponse(<Fallback label="Company" />, { ...size });
31 + const facts = companyFacts(d);
32 + return new ImageResponse(
33 + (
34 + <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}>
35 + <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
36 + <Eyebrow>{typeof d.attributes?.org_kind === 'string' && d.attributes.org_kind.toLowerCase() !== typeLabel(d.entity_type).toLowerCase() ? `${typeLabel(d.entity_type)} · ${d.attributes.org_kind}` : typeLabel(d.entity_type)}</Eyebrow>
37 + <Title>{d.name}</Title>
38 + {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Models, research, providers and sources.'}</div>}
39 + </div>
40 + </Frame>
41 + ),
42 + { ...size },
43 + );
44 +}
modified apps/web/src/app/companies/[slug]/page.tsx +5 −4
@@ -1,21 +1,22 @@
1 1 import type { Metadata } from 'next';
2 2 import { 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 { routes } from '@/lib/site';
7 7
8 type Params = { params: Promise<{ slug: string }> };
8 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> };
9 9
10 10 export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 11 const { slug } = await params;
12 12 return entityMetadata('companies', slug);
13 13 }
14 14
15 export default async function CompanyPage({ params }: Params) {
15 +export default async function CompanyPage({ params, searchParams }: Params) {
16 16 const { slug } = await params;
17 + const sp = await searchParams;
17 18 const d = await loadEntity('companies', slug);
18 19 if (d.slug !== slug) permanentRedirect(routes.entity(d));
19 20 const related = await safe(api.entityRelated(d.slug, 10));
20 return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />;
21 + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
21 22 }
modified apps/web/src/app/compare/page.tsx +73 −66
@@ -1,85 +1,92 @@
1 1 import type { Metadata } from 'next';
2 import Link from 'next/link';
2 +import { CompareMatrix, ComparePrices, SharedBenchmarks } from '@/components/compare/compare-matrix';
3 +import { ComparePicker } from '@/components/compare/compare-picker';
4 +import type { TrayItem } from '@/components/compare/compare-store';
3 5 import { EntityBadge } from '@/components/ui/badges';
4 import { DataTable, Td, Th } from '@/components/ui/data-table';
5 import { EntityLink } from '@/components/ui/entity';
6 import { ProvenanceInline } from '@/components/ui/provenance';
7 6 import { Container, Note, PageHeader } from '@/components/ui/section';
8 7 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 import { api, safe } from '@/lib/api';
10 import { fmtDate, fmtValue, num } from '@/lib/format';
11 import { routes, typeLabel } from '@/lib/site';
8 +import { api, ApiError, safe } from '@/lib/api';
9 +import { fmtInt } from '@/lib/format';
10 +import { routes, SITE_NAME, typeLabel } from '@/lib/site';
11 +import type { ComparePayload } from '@/lib/types';
12 12
13 export const metadata: Metadata = { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical: '/compare' } };
14 13 export const revalidate = 300;
15 14
16 export default async function ComparePage({ searchParams }: { searchParams: Promise<{ ids?: string }> }) {
17 const { ids: raw } = await searchParams;
18 const ids = (raw ?? '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 6);
15 +type SP = { ids?: string };
16 +
17 +function parseIds(raw: string | undefined): string[] {
18 + return [...new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean))].slice(0, 6);
19 +}
20 +
21 +/** Resolve the comparison, distinguishing a semantic 400 (mixed types / unknown slug) from an outage. */
22 +async function load(ids: string[]): Promise<{ res: ComparePayload | null; error: string | null }> {
23 + if (ids.length < 2) return { res: null, error: null };
24 + try {
25 + return { res: await api.compare(ids), error: null };
26 + } catch (e) {
27 + if (e instanceof ApiError && (e.status === 400 || e.status === 404 || e.status === 422)) return { res: null, error: e.detail ?? 'These entities cannot be compared.' };
28 + return { res: null, error: null };
29 + }
30 +}
31 +
32 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
33 + const ids = parseIds((await searchParams).ids);
19 34 const res = ids.length >= 2 ? await safe(api.compare(ids)) : null;
35 + const canonical = routes.compare(ids.length >= 2 ? ids : undefined);
36 + if (!res) return { title: 'Compare — models, providers, hardware side by side', description: 'Compare 2–6 entities of the same type on every recorded dimension, with the source of each value.', alternates: { canonical } };
37 + const names = res.items.map((i) => i.entity.name);
38 + const title = `${names.join(' vs ')} — ${typeLabel(res.entity_type).toLowerCase()} comparison`;
39 + const description = `${names.join(', ')} compared on ${res.dimensions.length} dimensions with the source of every value. ${SITE_NAME}.`.slice(0, 300);
40 + return { title, description, alternates: { canonical }, openGraph: { title, description } };
41 +}
42 +
43 +export default async function ComparePage({ searchParams }: { searchParams: Promise<SP> }) {
44 + const ids = parseIds((await searchParams).ids);
45 + const [{ res, error }, examples] = await Promise.all([load(ids), ids.length < 2 ? safe(api.models({ limit: 2, sort: 'quality' })) : Promise.resolve(null)]);
46 + const initial: TrayItem[] = res ? res.items.map((it) => ({ slug: it.entity.slug, name: it.entity.name, entity_type: it.entity.entity_type, organization: it.entity.organization?.name ?? null })) : [];
47 + const exampleHref = examples && examples.items.length >= 2 ? routes.compare(examples.items.slice(0, 2).map((m) => m.slug)) : null;
48 + const isModel = res?.entity_type === 'model';
49 +
20 50 return (
21 51 <Container wide>
22 <PageHeader eyebrow="Compare" title={res ? `Comparing ${res.items.length} ${typeLabel(res.entity_type, true).toLowerCase()}` : 'Compare side by side'} lede="Two to six entities of one type. Each cell shows the recorded value and, on hover, where it came from.">
23 <form action="/compare" method="get" className="mt-6 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
24 <label htmlFor="ids" className="sr-only">Slugs, comma separated</label>
25 <input id="ids" name="ids" defaultValue={ids.join(',')} placeholder="slugs separated by commas, e.g. claude-opus-5,gpt-5,gemini-3-pro" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} />
26 <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Compare</button>
27 </form>
28 <Note className="mt-2">Find slugs in any entity URL, or use “Add to a comparison” on entity pages. Picker UI is coming.</Note>
52 + <PageHeader
53 + eyebrow={res ? <><span>Compare</span><EntityBadge type={res.entity_type} small /></> : 'Compare'}
54 + title={res ? res.items.map((i) => i.entity.name).join(' vs ') : 'Compare side by side'}
55 + lede={res ? `${fmtInt(res.items.length)} ${typeLabel(res.entity_type, true).toLowerCase()} on ${fmtInt(res.dimensions.length)} recorded dimensions. Each cell shows the value as stated by its source.` : 'Two to six entities of one type — models, providers, hardware, frameworks or companies. Each cell shows the recorded value and where it came from.'}
56 + >
57 + <ComparePicker initial={initial} exampleHref={exampleHref} />
29 58 </PageHeader>
30 <div className="pb-16">
59 +
60 + <div className="space-y-12 pb-16">
31 61 {ids.length < 2 ? (
32 <EmptyState title="Enter at least two slugs">
33 Example: <Link href={routes.compare(['claude-opus-5', 'gpt-5'])} className="link">claude-opus-5 vs gpt-5</Link>
62 + <EmptyState title="Pick at least two entities of the same type">Search above, or press “Compare” on any model, provider or hardware listing — your selection is kept in this browser and in the URL.</EmptyState>
63 + ) : error ? (
64 + <EmptyState title="These entities cannot be compared together">
65 + {error}. Comparisons work across entities of one type (all models, all providers…). Remove the odd one out in the tray above.
34 66 </EmptyState>
35 67 ) : !res ? (
36 <Unavailable what="Comparison" reason="One of the slugs may not exist, or the entities are of different types." />
68 + <Unavailable what="Comparison" reason="The API did not answer. Try again in a moment." />
37 69 ) : (
38 70 <>
39 <DataTable scroll stack={false} caption="Comparison">
40 <thead>
41 <tr>
42 <Th>Dimension</Th>
43 {res.items.map((it) => (
44 <Th key={it.entity.id}>
45 <span className="flex flex-col items-start gap-0.5 normal-case tracking-normal">
46 <EntityBadge type={it.entity.entity_type} small />
47 <EntityLink e={it.entity} className="text-sm font-semibold" />
48 {it.entity.organization && <span className="text-[11px] font-normal text-ink-3">{it.entity.organization.name}</span>}
49 </span>
50 </Th>
51 ))}
52 </tr>
53 </thead>
54 <tbody>
55 {res.dimensions.map((dim) => {
56 const nums = res.items.map((it) => num(it.values[dim.key]));
57 const best = dim.kind === 'number' && nums.some((n) => n !== null) ? Math.max(...(nums.filter((n): n is number => n !== null))) : null;
58 return (
59 <tr key={dim.key}>
60 <Td className="text-ink-2">
61 {dim.label}
62 {dim.unit && <span className="text-ink-3"> · {dim.unit}</span>}
63 </Td>
64 {res.items.map((it) => {
65 const v = it.values[dim.key];
66 const p = it.provenance?.[dim.key];
67 const n = num(v);
68 return (
69 <Td key={it.entity.id} className={dim.kind === 'number' ? 'tnum' : undefined}>
70 <span className={best !== null && n === best && nums.filter((x) => x !== null).length > 1 ? 'font-semibold text-ink' : undefined} title={p ? `${p.source_name ?? p.url ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}>
71 {v === null || v === undefined || v === '' ? <span className="text-ink-3">Unavailable</span> : dim.kind === 'date' ? fmtDate(String(v)) : fmtValue(v, dim.key)}
72 </span>
73 {p && <ProvenanceInline p={p} showConfidence={false} className="hidden md:flex" />}
74 </Td>
75 );
76 })}
77 </tr>
78 );
79 })}
80 </tbody>
81 </DataTable>
82 <Note className="mt-3">Bold marks the highest number in a row; it is not a verdict. Benchmark rows only appear when all compared entities have a result under a comparable configuration.</Note>
71 + <section>
72 + <p className="eyebrow mb-2">Dimensions <span className="tnum text-ink-3">{res.dimensions.length}</span></p>
73 + <CompareMatrix res={res} />
74 + </section>
75 + {isModel && (
76 + <section>
77 + <p className="eyebrow mb-2">Shared benchmarks</p>
78 + <SharedBenchmarks res={res} />
79 + </section>
80 + )}
81 + {isModel && (
82 + <section>
83 + <p className="eyebrow mb-2">Prices · USD per 1M tokens</p>
84 + <ComparePrices res={res} />
85 + </section>
86 + )}
87 + <Note>
88 + Share this comparison with its URL. Values come from the atlas as of now; use each entity's History tab for how a value changed. <a href={routes.methodology()} className="link">How AI Atlas records facts →</a>
89 + </Note>
83 90 </>
84 91 )}
85 92 </div>
modified apps/web/src/app/datasets/page.tsx +73 −3
@@ -1,10 +1,80 @@
1 1 import type { Metadata } from 'next';
2 import { GenericListing } from '@/components/listing/generic-listing';
2 +import Link from 'next/link';
3 +import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing';
4 +import { Chip } from '@/components/ui/badges';
5 +import { EntityLink, QualityMark } from '@/components/ui/entity';
6 +import { api } from '@/lib/api';
7 +import { fmtAgo, fmtValue } from '@/lib/format';
8 +import { routes } from '@/lib/site';
3 9
4 export const metadata: Metadata = { title: 'AI datasets', description: 'Training and evaluation datasets with modality, size, license and publisher, linked to the models trained on them.', alternates: { canonical: '/datasets' } };
10 +export const metadata: Metadata = { title: 'AI datasets — modality, size, license', description: 'Training and evaluation datasets in the atlas with modality, size, license and publisher as stated by their hosts.', alternates: { canonical: '/datasets' } };
5 11 export const revalidate = 300;
6 12
7 13 export default async function DatasetsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 14 const sp = await searchParams;
9 return <GenericListing type="dataset" basePath="/datasets" eyebrow="Datasets" title="Datasets" lede="Training and evaluation corpora as documented on Hugging Face and publisher pages." searchParams={sp} />;
15 + return (
16 + <TypedListing
17 + title="Datasets"
18 + eyebrow="Datasets"
19 + lede="Training and evaluation datasets referenced by models and benchmarks. Modality, size and license are read from dataset cards and hosting pages."
20 + basePath="/datasets"
21 + searchParams={sp}
22 + fetch={(q) => api.explore('dataset', q)}
23 + sorts={[
24 + { value: 'updated', label: 'Recently updated' },
25 + { value: 'first_seen', label: 'Recently added' },
26 + { value: 'name', label: 'Name' },
27 + { value: 'quality', label: 'Data quality' },
28 + ]}
29 + filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
30 + emptyTitle="No datasets match"
31 + columns={[
32 + {
33 + key: 'name',
34 + label: 'Dataset',
35 + primary: true,
36 + render: (e) => (
37 + <>
38 + <EntityLink e={e} />
39 + {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
40 + </>
41 + ),
42 + },
43 + {
44 + key: 'modality',
45 + label: 'Modality',
46 + render: (e) => {
47 + const m = str(e.attributes?.modality) ? [str(e.attributes.modality) as string] : list(e.attributes?.modalities);
48 + return m.length ? (
49 + <span className="flex flex-wrap gap-1">
50 + {m.slice(0, 3).map((x) => (
51 + <Chip key={x}>{x}</Chip>
52 + ))}
53 + </span>
54 + ) : (
55 + <Dash />
56 + );
57 + },
58 + },
59 + { key: 'size', label: 'Size', className: 'tnum text-ink-2', render: (e) => (e.attributes?.size ? fmtValue(e.attributes.size, 'size') : <Dash />) },
60 + { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
61 + {
62 + key: 'publisher',
63 + label: 'Publisher',
64 + className: 'text-ink-2',
65 + render: (e) =>
66 + e.organization ? (
67 + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
68 + {e.organization.name}
69 + </Link>
70 + ) : (
71 + str(e.attributes?.publisher) ?? <Dash />
72 + ),
73 + },
74 + { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> },
75 + { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
76 + ]}
77 + note="Many datasets are known only by reference (a model card names them) and carry no attributes yet — the dash means the sources have not stated it."
78 + />
79 + );
10 80 }
modified apps/web/src/app/developers/page.tsx +135 −26
@@ -2,59 +2,155 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { DataTable, Td, Th } from '@/components/ui/data-table';
4 4 import { Container, Note, PageHeader, Section } from '@/components/ui/section';
5 +import { Unavailable } from '@/components/ui/unavailable';
5 6 import { api, safe } from '@/lib/api';
6 7 import { PUBLIC_API_BASE, routes } from '@/lib/site';
8 +import type { EntityDetail, Stats } from '@/lib/types';
7 9
8 export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, examples with curl.', alternates: { canonical: '/developers' } };
10 +export const metadata: Metadata = { title: 'Developers — public API', description: 'The AI Atlas public API: JSON over HTTPS, no key required for public routes, OpenAPI docs, live example responses and curl recipes.', alternates: { canonical: '/developers' } };
9 11 export const revalidate = 3600;
10 12
11 13 const ENDPOINTS: { path: string; returns: string }[] = [
12 14 { path: 'GET /health', returns: 'Service status (db, redis, llm) and version' },
13 { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size' },
15 + { path: 'GET /stats', returns: 'Live counters: entities per type, sources, documents, claims, events, prices, archive size — always computed from the database' },
14 16 { path: 'GET /stats/history?days=90', returns: 'Daily counts' },
15 { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search; returns the compiled filters and ranked entities' },
16 { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions' },
17 { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance, relations, sources, timeline and type-specific blocks (prices, results, lineage, hardware_fit, models, papers)' },
17 + { path: 'GET /search?q=&type=&limit=&offset=', returns: 'Natural-language or keyword search → { query: compiled filters, items: ranked entities (+rank), total }' },
18 + { path: 'GET /search/suggest?q=', returns: '≤ 8 prefix suggestions { id, entity_type, slug, name, organization_name }' },
19 + { path: 'GET /entities/{slug}', returns: 'Full entity detail: attributes, provenance per attribute, aliases, identifiers, relations (grouped), sources, timeline and type-specific blocks (prices, price_history, results, lineage, hardware_fit, models, papers, repositories)' },
18 20 { path: 'GET /models/{slug} · /companies/{slug} · /papers/{slug} · /providers/{slug} · /benchmarks/{slug} · /hardware/{slug} · /frameworks/{slug} · /datasets/{slug} · /tools/{slug}', returns: 'Same detail, type-checked (404 if the slug is another type)' },
19 { path: 'GET /entities/{slug}/timeline · /history?property= · /asof?date= · /graph?depth= · /sources · /related', returns: 'Events, full claim history, state as of a date, neighbourhood graph, documents, related entities' },
20 { path: 'GET /models?q=&org=&openness=&modality=&status=&min_params=&min_context=&year_from=&license=&sort=&facets=1', returns: 'Paged models with facets' },
21 { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model and paper counts' },
22 { path: 'GET /papers?q=&category=&org=&since=&until=', returns: 'Paged papers' },
23 { path: 'GET /providers · GET /prices?model=&provider=&current=1 · GET /prices/history · GET /prices/index?days=', returns: 'Providers, current prices, full price history, median/min price index and movers' },
24 { path: 'GET /benchmarks · GET /benchmarks/{slug}/results · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks, leaderboards (with config), result history' },
25 { path: 'GET /hardware · GET /hardware/fit?memory_gb=&quant=&context=', returns: 'Hardware listing and the estimated fit tool' },
26 { path: 'GET /explore/types · GET /explore/{type}', returns: 'Entity types with counts; generic listing for any type' },
27 { path: 'GET /changes?category=&type=&importance_min=&since=&before= · GET /changes/daily?date= · GET /changes/categories', returns: 'Change-event feed (cursor paged), daily digest, category counts' },
28 { path: 'GET /timeline?entity=&year=&category=', returns: 'Events grouped by month' },
29 { path: 'GET /compare?ids=a,b,c', returns: 'Side-by-side dimensions with provenance (2–6 entities of one type)' },
30 { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=', returns: 'What changed between two dates' },
31 { path: 'GET /sources · GET /methodology · GET /trending · GET /sitemap', returns: 'Transparency, vocabularies, most viewed, sitemap feed' },
21 + { path: 'GET /entities/{slug}/timeline?limit=&before=', returns: '{ items: ChangeEvent[], next_before } — the entity’s own events (and, for companies, those of the models it develops)' },
22 + { path: 'GET /entities/{slug}/history?property=', returns: '{ items: Claim[] } — every claim ever recorded, newest first, with status current | superseded | conflicting | retracted, valid_from → valid_to, source and tier' },
23 + { path: 'GET /entities/{slug}/asof?date=YYYY-MM-DD', returns: '{ existed, first_seen_at, date, attributes, claims } — the entity as it was known on that date' },
24 + { path: 'GET /entities/{slug}/graph?depth=1|2&limit=80', returns: '{ root, nodes: { id, slug, name, entity_type, organization_name }[], edges: { source, target, predicate }[] } — capped neighbourhood' },
25 + { path: 'GET /entities/{slug}/sources · /related?limit=', returns: 'Documents describing the entity (deduped by URL) · related entities (same org / family / shared relations)' },
26 + { path: 'GET /models?q=&org=&family=&openness=&modality=&status=&min_params=&max_params=&min_context=&year_from=&year_to=&license=&sort=&order=&facets=1', returns: 'Paged models (+facets: organizations, openness, modalities, families, years, licenses, status)' },
27 + { path: 'GET /companies?q=&country=&kind=&sort=', returns: 'Paged organizations with model_count and paper_count (+facets)' },
28 + { path: 'GET /papers?q=&category=&org=&since=&until=&sort=', returns: 'Paged papers' },
29 + { path: 'GET /providers · GET /prices?model=&provider=&sort=&current=1 · GET /prices/history?model=|provider=', returns: 'Providers with min prices · current price rows (USD / 1M tokens) · every price row incl. closed ones, oldest first' },
30 + { path: 'GET /prices/index?days=180', returns: '{ days, series: { day, median_input, median_output, min_input, max_input, models, offers }[], movers: ChangeEvent[] (PRICE_CHANGED), note }' },
31 + { path: 'GET /benchmarks · GET /benchmarks/{slug}/results?config=&history=1&limit=&offset= · GET /benchmarks/{slug}/history?model=', returns: 'Benchmarks with result_count, model_count and current top · leaderboard sorted by score (respecting higher_is_better), current rows only unless history=1, filterable by config key=value · one model’s results over time' },
32 + { path: 'GET /hardware?kind=&manufacturer=&min_memory=&sort= · GET /hardware/fit?memory_gb=&quant=4bit|8bit|fp16&context=8192&openness=&limit=', returns: 'Hardware listing · ESTIMATED fit: { inputs, estimated: true, assumptions[], counts, items: { model, parameter_count, estimated_memory_gb, headroom_gb, fits, quantization, note }[] }' },
33 + { path: 'GET /explore/types · GET /explore/{type}?q=&org=&sort=', returns: 'Entity types with live counts; generic paged listing for any type' },
34 + { path: 'GET /changes?category=&type=&entity_type=&importance_min=&since=&until=&q=&limit=&before= · GET /changes/daily?date= · GET /changes/categories?days=', returns: 'Change-event feed (cursor paged, next_before) · daily digest with sections · category counts' },
35 + { path: 'GET /timeline?entity=&year=&category=&limit=', returns: '{ items: { month, count, events[] }[], total } — grouped by month' },
36 + { path: 'GET /compare?ids=a,b[,…6]', returns: '{ entity_type, dimensions: { key, label, unit, kind: number|text|list|bool|date, source }[], items: { entity, values, provenance, prices?, results? }[] } — 2–6 entities of one type; models add best prices and the benchmarks shared by all' },
37 + { path: 'GET /diff?a=YYYY-MM-DD&b=YYYY-MM-DD&scope=all|models|org:<slug>|family:<name>', returns: '{ a, b, scope, new_entities[], gone_entities[], property_changes[], price_changes[], benchmark_changes[], counts }' },
38 + { path: 'GET /sources · GET /methodology · GET /trending?days=&limit=&type= · GET /sitemap?type=&limit=&offset=', returns: 'Transparency page data, vocabularies and tiers, most viewed entities, sitemap feed' },
32 39 ];
33 40
41 +/** Compact, honest projection of /stats for the example block (the real payload has more counters). */
42 +function statsExample(s: Stats) {
43 + return {
44 + entities: s.entities,
45 + entities_total: s.entities_total,
46 + sources: s.sources,
47 + connectors: s.connectors,
48 + claims_current: s.claims_current,
49 + change_events_24h: s.change_events_24h,
50 + benchmark_results: s.benchmark_results,
51 + prices_current: s.prices_current,
52 + last_event_at: s.last_event_at,
53 + computed_at: s.computed_at,
54 + '…': `${Object.keys(s).length} keys in total`,
55 + };
56 +}
57 +
58 +function modelExample(d: EntityDetail) {
59 + const attrKeys = Object.keys(d.attributes ?? {}).slice(0, 8);
60 + const attributes: Record<string, unknown> = {};
61 + for (const k of attrKeys) attributes[k] = d.attributes[k];
62 + const provKey = attrKeys.find((k) => d.provenance?.[k]) ?? Object.keys(d.provenance ?? {})[0];
63 + const rest = Object.keys(d.attributes ?? {}).length - attrKeys.length;
64 + return {
65 + id: d.id,
66 + entity_type: d.entity_type,
67 + slug: d.slug,
68 + name: d.name,
69 + organization: d.organization,
70 + status: d.status,
71 + attributes: rest > 0 ? { ...attributes, '…': `${rest} more` } : attributes,
72 + provenance: provKey ? { [provKey]: d.provenance[provKey], '…': `one entry per attribute` } : {},
73 + quality: d.quality,
74 + counts: d.counts,
75 + relations: `${d.relations?.length ?? 0} groups`,
76 + prices: `${d.prices?.length ?? 0} current rows`,
77 + results: `${d.results?.length ?? 0} benchmark results`,
78 + timeline: `${d.timeline?.length ?? 0} events`,
79 + updated_at: d.updated_at,
80 + };
81 +}
82 +
83 +function Code({ children }: { children: string }) {
84 + return (
85 + <pre className="scrollbar-thin mt-3 overflow-x-auto border border-rule bg-surface p-4 text-[12.5px] leading-relaxed text-ink">
86 + <code>{children}</code>
87 + </pre>
88 + );
89 +}
90 +
34 91 export default async function DevelopersPage() {
35 const health = await safe(api.health());
92 + const [health, stats, top] = await Promise.all([safe(api.health()), safe(api.stats()), safe(api.models({ limit: 2, sort: 'quality' }))]);
93 + const slugA = top?.items[0]?.slug;
94 + const slugB = top?.items[1]?.slug;
95 + const model = slugA ? await safe(api.entityOfType('models', slugA)) : null;
96 + const exA = slugA ?? '<model-slug>';
97 + const exB = slugB ?? '<other-slug>';
98 + const today = new Date().toISOString().slice(0, 10);
99 + const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
100 +
36 101 return (
37 102 <Container>
38 103 <PageHeader eyebrow="Developers" title="Public API" lede="Everything on this site comes from the same JSON API. Public routes need no key; responses are cached for 1–10 minutes and search is rate-limited per IP." aside={health ? <p className="text-sm text-ink-3">API <span className={health.status === 'ok' ? 'text-positive' : 'text-warning'}>{health.status}</span> · v{health.version}</p> : <p className="text-sm text-ink-3">API status unavailable</p>} />
39 104
40 105 <Section eyebrow="Base URL" title={<span className="mono text-lg md:text-xl">{PUBLIC_API_BASE}</span>} hairline={false}>
41 106 <p className="text-sm text-ink-2">
42 Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503.
107 + Interactive OpenAPI documentation: <a href="/api/v1/docs" className="link mono">/api/v1/docs</a>. JSON, UTF-8, ISO-8601 UTC timestamps. Errors are <span className="mono">{'{ "detail": "…" }'}</span> with 400 / 404 / 429 / 503 (422 for parameters rejected by validation).
43 108 </p>
44 <pre className="scrollbar-thin mt-4 overflow-x-auto border border-rule bg-surface p-4 text-[13px] leading-relaxed text-ink"><code>{`# Live counters
109 + <Code>{`# Live counters
45 110 curl -s ${PUBLIC_API_BASE}/stats | jq '.entities'
46 111
47 112 # Natural-language search → compiled filters + ranked results
48 113 curl -s "${PUBLIC_API_BASE}/search?q=open+models+over+100B+released+in+2026" | jq '.query, .items[0:3]'
49 114
50 115 # A model with provenance, prices, results, lineage
51 curl -s ${PUBLIC_API_BASE}/models/claude-opus-5 | jq '{name, attributes, provenance: (.provenance | keys)}'
116 +curl -s ${PUBLIC_API_BASE}/models/${exA} | jq '{name, attributes, provenance: (.provenance | keys)}'
52 117
53 # Price history for one model across providers
54 curl -s "${PUBLIC_API_BASE}/prices/history?model=claude-opus-5" | jq '.items[] | {provider: .provider.name, input_per_mtok, valid_from, valid_to}'
118 +# Compare two models on every shared dimension (+ shared benchmarks, best prices)
119 +curl -s "${PUBLIC_API_BASE}/compare?ids=${exA},${exB}" | jq '.dimensions[].key, .items[].values'
120 +
121 +# What changed between two dates (scope: all | models | org:<slug> | family:<name>)
122 +curl -s "${PUBLIC_API_BASE}/diff?a=${weekAgo}&b=${today}&scope=models" | jq '.counts, .new_entities[0:3]'
123 +
124 +# Claim history and the state of a record as of a date
125 +curl -s "${PUBLIC_API_BASE}/entities/${exA}/history?property=context_length" | jq '.items[] | {value, status, valid_from, valid_to, source_url}'
126 +curl -s "${PUBLIC_API_BASE}/entities/${exA}/asof?date=${weekAgo}" | jq '{existed, attributes}'
127 +
128 +# Neighbourhood graph, price index, leaderboard by config, estimated hardware fit
129 +curl -s "${PUBLIC_API_BASE}/entities/${exA}/graph?depth=1&limit=80" | jq '{nodes: (.nodes|length), edges: (.edges|length)}'
130 +curl -s "${PUBLIC_API_BASE}/prices/index?days=90" | jq '.series[-1], (.movers|length)'
131 +curl -s "${PUBLIC_API_BASE}/benchmarks/<benchmark-slug>/results?config=variant=v2.1&limit=20" | jq '.items[] | {model: .model.name, score, config}'
132 +curl -s "${PUBLIC_API_BASE}/hardware/fit?memory_gb=64&quant=4bit&context=32768&limit=20" | jq '.assumptions, .items[0]'
55 133
56 134 # What changed today
57 curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</code></pre>
135 +curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] | {event_type, summary, source_url}'`}</Code>
136 + </Section>
137 +
138 + <Section eyebrow="Live examples" title="Example responses" lede="Fetched from the API when this page was rendered (revalidated hourly) and trimmed for display — not sample data.">
139 + <div className="grid gap-8 lg:grid-cols-2">
140 + <div className="min-w-0">
141 + <p className="mono text-sm text-ink">GET /stats</p>
142 + {stats ? <Code>{JSON.stringify(statsExample(stats), null, 2)}</Code> : <Unavailable what="Live /stats example" className="mt-3" />}
143 + </div>
144 + <div className="min-w-0">
145 + <p className="mono text-sm text-ink">GET /models/{exA}</p>
146 + {model ? <Code>{JSON.stringify(modelExample(model), null, 2)}</Code> : <Unavailable what="Live model example" className="mt-3" />}
147 + {model && (
148 + <Note className="mt-2">
149 + Trimmed: the full record has every attribute, a provenance entry per attribute, grouped relations, sources and the latest 30 events — <Link href={routes.entity(model)} className="link">see it rendered</Link> or <a href={`/api/v1/models/${encodeURIComponent(model.slug)}`} className="link">fetch the JSON</a>.
150 + </Note>
151 + )}
152 + </div>
153 + </div>
58 154 </Section>
59 155
60 156 <Section eyebrow="Endpoints" title="Public routes">
@@ -69,7 +165,20 @@ curl -s "${PUBLIC_API_BASE}/changes?importance_min=2&limit=20" | jq '.items[] |
69 165 ))}
70 166 </tbody>
71 167 </DataTable>
72 <Note className="mt-3">Shapes are documented in the OpenAPI schema. Postgres aggregates may arrive as strings; treat numeric fields as <span className="mono">number | string | null</span>.</Note>
168 + <Note className="mt-3">Shapes are documented in the OpenAPI schema. Admin routes (<span className="mono">/admin/*</span>) require the <span className="mono">x-aia-admin-token</span> header and are not public.</Note>
169 + </Section>
170 +
171 + <Section eyebrow="Conventions" title="Reading responses">
172 + <ul className="max-w-3xl list-disc space-y-1.5 pl-5 text-sm leading-relaxed text-ink-2">
173 + <li>Numeric aggregates may arrive as <span className="mono">string</span> (Postgres decimals): treat every numeric field as <span className="mono">number | string | null</span> and coerce.</li>
174 + <li><span className="mono">null</span> means the sources did not state it. Never read a missing field as zero, and never average two conflicting claims — the API returns both, flagged.</li>
175 + <li>Timestamps are ISO-8601 UTC. Dates without time (<span className="mono">release_date</span>, <span className="mono">knowledge_cutoff</span>) may be <span className="mono">YYYY</span>, <span className="mono">YYYY-MM</span> or <span className="mono">YYYY-MM-DD</span>, exactly as published.</li>
176 + <li>Units: tokens, USD per 1M tokens (<span className="mono">*_per_mtok</span>), GB, GB/s, W. Openness vocabulary: <span className="mono">open-weights | open-source | proprietary | restricted</span>.</li>
177 + <li><span className="mono">provenance</span> is an object keyed by attribute: <span className="mono">{'{ source_id, url, observed_at, tier (1 official → 4 unverified), confidence, extractor: deterministic | llm, unit? }'}</span>.</li>
178 + <li>Cursor feeds (<span className="mono">/changes</span>, <span className="mono">/entities/{'{slug}'}/timeline</span>) return <span className="mono">next_before</span>; pass it as <span className="mono">before=</span> for the next page (<span className="mono">null</span> at the end). Listings otherwise use <span className="mono">limit</span>/<span className="mono">offset</span>, capped at 200 (sitemap: 5 000).</li>
179 + <li>Parameters rejected by validation (bad type or bounds) return <span className="mono">422</span> with <span className="mono">{'{ detail, errors }'}</span>; semantic errors (unknown sort, bad date, wrong scope, ids count) return <span className="mono">400</span>.</li>
180 + <li>Anything marked <span className="mono">estimated</span> (hardware fit) is derived from published specs by a stated formula — the <span className="mono">assumptions</span> array is part of the response.</li>
181 + </ul>
73 182 </Section>
74 183
75 184 <Section eyebrow="Terms" title="Fair use">
added apps/web/src/app/diff/page.tsx +193 −0
@@ -0,0 +1,193 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ChangeRow } from '@/components/changes/change-row';
4 +import { EntityRow } from '@/components/ui/entity';
5 +import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
6 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
7 +import { api, ApiError, safe } from '@/lib/api';
8 +import { fmtDate, fmtInt, num } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import type { ChangeEvent, DiffPayload, EntitySummary } from '@/lib/types';
11 +
12 +type SP = { a?: string; b?: string; scope?: string; scope_custom?: string };
13 +const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
14 +
15 +function todayUtc(): string {
16 + return new Date().toISOString().slice(0, 10);
17 +}
18 +function daysBefore(iso: string, days: number): string {
19 + const d = new Date(`${iso}T00:00:00Z`);
20 + d.setUTCDate(d.getUTCDate() - days);
21 + return d.toISOString().slice(0, 10);
22 +}
23 +function resolve(sp: SP): { a: string; b: string; scope: string; valid: boolean } {
24 + const b = sp.b && ISO_DAY.test(sp.b) ? sp.b : todayUtc();
25 + const a = sp.a && ISO_DAY.test(sp.a) ? sp.a : daysBefore(b, 7);
26 + const scope = (sp.scope_custom?.trim() || sp.scope || 'all').trim() || 'all';
27 + const valid = (!sp.a || ISO_DAY.test(sp.a)) && (!sp.b || ISO_DAY.test(sp.b)) && a < b;
28 + return { a, b, scope, valid };
29 +}
30 +
31 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
32 + const { a, b, scope } = resolve(await searchParams);
33 + const title = `What changed between ${fmtDate(a)} and ${fmtDate(b)}${scope !== 'all' ? ` (${scope})` : ''}`;
34 + return { title, description: `New, gone and changed entities, prices and benchmark results in the AI ecosystem between ${a} and ${b}, straight from the AI Atlas change log.`, alternates: { canonical: routes.diff({ a, b, scope }) }, robots: { index: false } };
35 +}
36 +
37 +function describeScope(s: DiffPayload['scope'], fallback: string): string {
38 + if (!s) return fallback === 'all' ? 'the whole atlas' : fallback;
39 + if (typeof s === 'string') return s;
40 + const kind = typeof s.kind === 'string' ? s.kind : fallback;
41 + const parts = Object.entries(s)
42 + .filter(([k, v]) => k !== 'kind' && v !== null && v !== undefined && v !== '')
43 + .map(([k, v]) => `${k} ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`);
44 + if (kind === 'all') return 'the whole atlas';
45 + return parts.length ? `${kind} · ${parts.join(' · ')}` : kind;
46 +}
47 +
48 +function Capped({ shown, total, what }: { shown: number; total: number | null; what: string }) {
49 + if (total === null || total <= shown) return null;
50 + return <Note className="mt-2">Showing the first {fmtInt(shown)} of {fmtInt(total)} {what}. Narrow the scope or the dates to see everything.</Note>;
51 +}
52 +
53 +function EventsSection({ id, title, items, total, empty }: { id: string; title: string; items: ChangeEvent[]; total: number | null; empty: string }) {
54 + return (
55 + <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>
56 + {items.length === 0 ? (
57 + <EmptyState title={empty} />
58 + ) : (
59 + <>
60 + <ul className="border-t border-rule">
61 + {items.map((e) => (
62 + <ChangeRow key={e.id} e={e} showDate live={false} />
63 + ))}
64 + </ul>
65 + <Capped shown={items.length} total={total} what="events" />
66 + </>
67 + )}
68 + </Section>
69 + );
70 +}
71 +
72 +function EntitiesSection({ id, title, items, total, empty }: { id: string; title: string; items: EntitySummary[]; total: number | null; empty: string }) {
73 + return (
74 + <Section id={id} eyebrow={title} title={<>{title} <span className="tnum text-base font-normal text-ink-3">{fmtInt(total ?? items.length)}</span></>}>
75 + {items.length === 0 ? (
76 + <EmptyState title={empty} />
77 + ) : (
78 + <>
79 + <ul className="border-t border-rule">
80 + {items.map((e) => (
81 + <EntityRow key={e.id} e={e} showType showDescription={false} />
82 + ))}
83 + </ul>
84 + <Capped shown={items.length} total={total} what="entities" />
85 + </>
86 + )}
87 + </Section>
88 + );
89 +}
90 +
91 +export default async function DiffPage({ searchParams }: { searchParams: Promise<SP> }) {
92 + const sp = await searchParams;
93 + const { a, b, scope, valid } = resolve(sp);
94 + const orgs = await safe(api.companies({ limit: 30, sort: 'models' }));
95 + let payload: DiffPayload | null = null;
96 + let error: string | null = null;
97 + if (!valid) error = 'Dates must be YYYY-MM-DD and the first date must come before the second.';
98 + else {
99 + try {
100 + payload = await api.diff(a, b, scope);
101 + } catch (e) {
102 + error = e instanceof ApiError && e.status === 400 ? e.detail ?? 'The API rejected these parameters.' : null;
103 + }
104 + }
105 + const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none';
106 + const orgOptions = (orgs?.items ?? []).map((o) => ({ value: `org:${o.slug}`, label: `${o.name} (${fmtInt(o.model_count)} models)` }));
107 + const knownScopes = new Set(['all', 'models', ...orgOptions.map((o) => o.value)]);
108 + const custom = knownScopes.has(scope) ? '' : scope;
109 + const c = payload?.counts ?? {};
110 + const cnt = (k: string): number | null => num(c[k]);
111 +
112 + return (
113 + <Container>
114 + <PageHeader eyebrow="Diff" title={<>What changed between <span className="tnum">{fmtDate(a)}</span> and <span className="tnum">{fmtDate(b)}</span></>} lede="Two dates, one scope: the entities that appeared or disappeared, and every property, price and benchmark change recorded in between. Built from the change log — nothing is inferred." aside={payload ? <p className="text-sm text-ink-3">Scope: <span className="text-ink-2">{describeScope(payload.scope, scope)}</span></p> : undefined}>
115 + <form action="/diff" method="get" className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
116 + <label className="block min-w-0">
117 + <span className="eyebrow block pb-1">From (UTC)</span>
118 + <input type="date" name="a" defaultValue={a} max={todayUtc()} className={cls} required />
119 + </label>
120 + <label className="block min-w-0">
121 + <span className="eyebrow block pb-1">To (UTC)</span>
122 + <input type="date" name="b" defaultValue={b} max={todayUtc()} className={cls} required />
123 + </label>
124 + <label className="block min-w-0">
125 + <span className="eyebrow block pb-1">Scope</span>
126 + <select name="scope" defaultValue={custom ? '' : scope} className={cls}>
127 + <option value="all">Everything</option>
128 + <option value="models">Models only</option>
129 + {orgOptions.length > 0 && (
130 + <optgroup label="Organization">
131 + {orgOptions.map((o) => (
132 + <option key={o.value} value={o.value}>
133 + {o.label}
134 + </option>
135 + ))}
136 + </optgroup>
137 + )}
138 + {custom && <option value="">Custom (below)</option>}
139 + </select>
140 + </label>
141 + <label className="block min-w-0">
142 + <span className="eyebrow block pb-1">Custom scope</span>
143 + <input name="scope_custom" defaultValue={custom} placeholder="family:Claude · org:<slug>" className={cls} />
144 + </label>
145 + <div className="flex items-end gap-2">
146 + <button type="submit" className="inline-flex h-11 flex-1 items-center justify-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90">
147 + Compare dates
148 + </button>
149 + <Link href={routes.diff()} className="inline-flex h-11 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">
150 + Reset
151 + </Link>
152 + </div>
153 + </form>
154 + <Note className="mt-2">
155 + Custom scope accepts <span className="mono">org:&lt;slug&gt;</span> or <span className="mono">family:&lt;name&gt;</span>; when filled it overrides the select. Quick links: <Link href={routes.diff({ a: daysBefore(todayUtc(), 1), b: todayUtc() })} className="link">yesterday → today</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 7), b: todayUtc(), scope: 'models' })} className="link">last 7 days, models</Link> · <Link href={routes.diff({ a: daysBefore(todayUtc(), 30), b: todayUtc() })} className="link">last 30 days</Link>
156 + </Note>
157 + </PageHeader>
158 +
159 + <div className="pb-16">
160 + {error ? (
161 + <Unavailable what="Diff" reason={error} />
162 + ) : !payload ? (
163 + <Unavailable what="Diff" reason="The change log could not be read right now." />
164 + ) : (
165 + <>
166 + <StatGrid cols={8}>
167 + <Stat label="New entities" value={fmtInt(cnt('new_entities') ?? payload.new_entities.length)} href="#new" />
168 + <Stat label="Gone" value={fmtInt(cnt('gone_entities') ?? payload.gone_entities.length)} href="#gone" />
169 + <Stat label="Events" value={fmtInt(cnt('events'))} hint="all types" />
170 + <Stat label="Price changes" value={fmtInt(cnt('price_changes') ?? payload.price_changes.length)} href="#prices" accent />
171 + <Stat label="Benchmark changes" value={fmtInt(cnt('benchmark_changes') ?? payload.benchmark_changes.length)} href="#benchmarks" />
172 + <Stat label="Claims superseded" value={fmtInt(cnt('claims_superseded'))} />
173 + <Stat label={`Entities at ${fmtDate(a)}`} value={fmtInt(cnt('entities_at_a'))} />
174 + <Stat label={`Entities at ${fmtDate(b)}`} value={fmtInt(cnt('entities_at_b'))} />
175 + </StatGrid>
176 + <Note className="mt-2">
177 + Price rows opened {fmtInt(cnt('price_rows_opened'))} · closed {fmtInt(cnt('price_rows_closed'))}. Counts are totals over the period; the lists below are capped by the API.
178 + </Note>
179 +
180 + <EntitiesSection id="new" title="New entities" items={payload.new_entities} total={cnt('new_entities')} empty="No new entities between these dates" />
181 + <EntitiesSection id="gone" title="Gone entities" items={payload.gone_entities} total={cnt('gone_entities')} empty="No entities disappeared between these dates" />
182 + <EventsSection id="properties" title="Property changes" items={payload.property_changes} total={cnt('property_changes')} empty="No property changes between these dates" />
183 + <EventsSection id="prices" title="Price changes" items={payload.price_changes} total={cnt('price_changes')} empty="No price changes between these dates" />
184 + <EventsSection id="benchmarks" title="Benchmark changes" items={payload.benchmark_changes} total={cnt('benchmark_changes')} empty="No benchmark changes between these dates" />
185 + <Note className="mt-6">
186 + “Gone” means an entity present at the first date is no longer current at the second (merged or retired) — its record and history are kept. Per-entity history: open any entity's History tab. <Link href="/methodology" className="link">Methodology →</Link>
187 + </Note>
188 + </>
189 + )}
190 + </div>
191 + </Container>
192 + );
193 +}
modified apps/web/src/app/explore/[type]/[slug]/page.tsx +5 −4
@@ -1,12 +1,12 @@
1 1 import type { Metadata } from 'next';
2 2 import { 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 { buildMetadata, loadAnyEntity } from '@/components/entity/load';
5 5 import { api, safe } from '@/lib/api';
6 6 import { routes, TYPE_PATH } from '@/lib/site';
7 7
8 8 /** Fallback detail page for entity types without a dedicated path (regulation, incident, researcher, …). */
9 type Params = { params: Promise<{ type: string; slug: string }> };
9 +type Params = { params: Promise<{ type: string; slug: string }>; searchParams: Promise<EntityPageParams> };
10 10
11 11 export async function generateMetadata({ params }: Params): Promise<Metadata> {
12 12 const { type, slug } = await params;
@@ -15,10 +15,11 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
15 15 return buildMetadata(d);
16 16 }
17 17
18 export default async function ExploreEntityPage({ params }: Params) {
18 +export default async function ExploreEntityPage({ params, searchParams }: Params) {
19 19 const { type, slug } = await params;
20 + const sp = await searchParams;
20 21 const d = await loadAnyEntity(type, slug);
21 22 if (TYPE_PATH[d.entity_type]) permanentRedirect(routes.entity(d));
22 23 const related = await safe(api.entityRelated(d.slug, 10));
23 return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />;
24 + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
24 25 }
modified apps/web/src/app/frameworks/page.tsx +63 −3
@@ -1,10 +1,70 @@
1 1 import type { Metadata } from 'next';
2 import { GenericListing } from '@/components/listing/generic-listing';
2 +import Link from 'next/link';
3 +import { Dash, str, TypedListing } from '@/components/listing/typed-listing';
4 +import { EntityBadge } from '@/components/ui/badges';
5 +import { EntityLink, QualityMark } from '@/components/ui/entity';
6 +import { api } from '@/lib/api';
7 +import { fmtDate, fmtInt, num } from '@/lib/format';
8 +import { routes } from '@/lib/site';
3 9
4 export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes', description: 'Training, inference and agent frameworks with versions, licenses and repository metrics from their official sources.', alternates: { canonical: '/frameworks' } };
10 +export const metadata: Metadata = { title: 'AI frameworks, libraries and runtimes — versions, licenses, stars', description: 'Training, inference and agent frameworks with latest version, release date, license and repository stars read from PyPI, GitHub and release pages.', alternates: { canonical: '/frameworks' } };
5 11 export const revalidate = 300;
6 12
7 13 export default async function FrameworksPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 14 const sp = await searchParams;
9 return <GenericListing type="framework" basePath="/frameworks" eyebrow="Frameworks" title="Frameworks & runtimes" lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages." searchParams={sp} />;
15 + return (
16 + <TypedListing
17 + title="Frameworks & runtimes"
18 + eyebrow="Frameworks"
19 + lede="Libraries, inference engines and agent frameworks. Versions and stars are read from PyPI, GitHub and release pages — never estimated."
20 + basePath="/frameworks"
21 + searchParams={sp}
22 + fetch={(q) => api.explore('framework', q)}
23 + sorts={[
24 + { value: 'updated', label: 'Recently updated' },
25 + { value: 'stars', label: 'Stars' },
26 + { value: 'release', label: 'Latest release' },
27 + { value: 'name', label: 'Name' },
28 + { value: 'quality', label: 'Data quality' },
29 + ]}
30 + filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
31 + compare
32 + columns={[
33 + {
34 + key: 'name',
35 + label: 'Framework',
36 + primary: true,
37 + render: (e) => (
38 + <>
39 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
40 + <EntityLink e={e} />
41 + {e.entity_type !== 'framework' && <EntityBadge type={e.entity_type} small />}
42 + </span>
43 + {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
44 + </>
45 + ),
46 + },
47 + { key: 'version', label: 'Latest version', className: 'mono text-xs text-ink', render: (e) => str(e.attributes?.latest_version) ?? <Dash /> },
48 + { key: 'release', label: 'Release date', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.latest_release_at) ? fmtDate(str(e.attributes.latest_release_at)) : <Dash />) },
49 + { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
50 + { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) },
51 + { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> },
52 + {
53 + key: 'org',
54 + label: 'Organization',
55 + className: 'text-ink-2',
56 + render: (e) =>
57 + e.organization ? (
58 + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
59 + {e.organization.name}
60 + </Link>
61 + ) : (
62 + <Dash />
63 + ),
64 + },
65 + { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
66 + ]}
67 + note="Stars are a GitHub metric observed at crawl time (see each framework's Sources tab for the snapshot). Versions come from PyPI or the project's release feed."
68 + />
69 + );
10 70 }
added apps/web/src/app/graph/[slug]/page.tsx +163 −0
@@ -0,0 +1,163 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { GraphExplorer } from '@/components/graph/graph-explorer';
5 +import { EntityBadge } from '@/components/ui/badges';
6 +import { EntityLink } from '@/components/ui/entity';
7 +import { Container, Note, PageHeader } from '@/components/ui/section';
8 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 +import { api, ApiError, safe } from '@/lib/api';
10 +import { fmtInt } from '@/lib/format';
11 +import { predicateLabel, routes, SITE_NAME, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';
12 +import type { GraphNode, GraphPayload } from '@/lib/types';
13 +
14 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<{ depth?: string }> };
15 +const CAP = 80;
16 +
17 +/** 404 → notFound(); any other failure → null (the page renders an Unavailable state). */
18 +async function loadGraph(slug: string, depth: 1 | 2): Promise<GraphPayload | null> {
19 + try {
20 + return await api.entityGraph(slug, depth, CAP);
21 + } catch (e) {
22 + if (e instanceof ApiError && e.notFound) notFound();
23 + return null;
24 + }
25 +}
26 +
27 +export async function generateMetadata({ params }: Params): Promise<Metadata> {
28 + const { slug } = await params;
29 + const d = await safe(api.entity(slug));
30 + if (!d) return { title: 'Graph', robots: { index: false } };
31 + const title = `${d.name} — relationship graph`;
32 + return { title, description: `Everything AI Atlas links to ${d.name} (${typeLabel(d.entity_type).toLowerCase()}): organizations, models, benchmarks, papers and providers, with the predicate of each relation.`, alternates: { canonical: routes.graph(d.slug) }, openGraph: { title: `${title} | ${SITE_NAME}`, type: 'article' } };
33 +}
34 +
35 +export default async function GraphPage({ params, searchParams }: Params) {
36 + const { slug } = await params;
37 + const sp = await searchParams;
38 + const depth: 1 | 2 = sp.depth === '2' ? 2 : 1;
39 + const [graph, d] = await Promise.all([loadGraph(slug, depth), safe(api.entity(slug))]);
40 + const rootId = graph?.root ?? d?.id ?? '';
41 + const nodes = graph?.nodes ?? [];
42 + const edges = graph?.edges ?? [];
43 + const capped = nodes.length >= CAP;
44 + const byId = new Map(nodes.map((n) => [n.id, n]));
45 + // Text fallback grouped by predicate (root's own edges first, then the rest).
46 + const groups = new Map<string, { node: GraphNode; direction: 'out' | 'in' }[]>();
47 + for (const e of edges) {
48 + const isOut = e.source === rootId;
49 + const isIn = e.target === rootId;
50 + if (!isOut && !isIn) continue;
51 + const other = byId.get(isOut ? e.target : e.source);
52 + if (!other) continue;
53 + const key = `${e.predicate}|${isOut ? 'out' : 'in'}`;
54 + (groups.get(key) ?? groups.set(key, []).get(key)!).push({ node: other, direction: isOut ? 'out' : 'in' });
55 + }
56 + const secondOrder = edges.filter((e) => e.source !== rootId && e.target !== rootId).length;
57 + const predicateCounts = [...edges.reduce((m, e) => m.set(e.predicate, (m.get(e.predicate) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]);
58 + const typeCounts = [...nodes.reduce((m, n) => m.set(n.entity_type, (m.get(n.entity_type) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]);
59 + const depthLink = (n: 1 | 2) => (n === depth ? 'bg-ink text-canvas' : 'text-ink-2 hover:text-ink');
60 +
61 + return (
62 + <Container wide>
63 + <nav aria-label="Breadcrumb" className="pt-5 text-xs text-ink-3">
64 + <ol className="flex flex-wrap items-center gap-1.5">
65 + <li><Link href="/" className="hover:text-ink">AI Atlas</Link></li>
66 + {d && (
67 + <>
68 + <li aria-hidden>/</li>
69 + <li><Link href={routes.listing(d.entity_type)} className="hover:text-ink">{typeLabel(d.entity_type, true)}</Link></li>
70 + <li aria-hidden>/</li>
71 + <li><Link href={routes.entity(d)} className="hover:text-ink">{d.name}</Link></li>
72 + </>
73 + )}
74 + <li aria-hidden>/</li>
75 + <li className="text-ink-2">Graph</li>
76 + </ol>
77 + </nav>
78 + <PageHeader
79 + eyebrow={<>Graph explorer {d && <EntityBadge type={d.entity_type} small />}</>}
80 + title={d ? <>Around <Link href={routes.entity(d)} className="hover:text-accent">{d.name}</Link></> : `Around ${slug}`}
81 + lede={d ? `Every recorded relation of ${d.name}${depth === 2 ? ' and of its neighbours' : ''}, coloured by entity type. Click a node to open it.` : undefined}
82 + aside={
83 + <div className="flex flex-col items-start gap-2 md:items-end">
84 + <div className="inline-flex border border-rule text-sm" role="group" aria-label="Depth">
85 + <Link href={routes.graph(slug, 1)} className={`inline-flex h-11 items-center px-3 ${depthLink(1)}`} aria-current={depth === 1 ? 'true' : undefined}>Depth 1</Link>
86 + <Link href={routes.graph(slug, 2)} className={`inline-flex h-11 items-center border-l border-rule px-3 ${depthLink(2)}`} aria-current={depth === 2 ? 'true' : undefined}>Depth 2</Link>
87 + </div>
88 + {graph && <p className="tnum text-xs text-ink-3">{fmtInt(nodes.length)} nodes · {fmtInt(edges.length)} edges{capped ? ` · capped at ${CAP}` : ''}</p>}
89 + </div>
90 + }
91 + />
92 + <div className="pb-16">
93 + {!graph ? (
94 + <Unavailable what="Graph" />
95 + ) : nodes.length <= 1 || edges.length === 0 ? (
96 + <EmptyState title="No relations recorded yet">
97 + Relations are written only when a source states them (developer, provider, benchmark, paper…). {d && <Link href={routes.entity(d)} className="link">Back to {d.name} →</Link>}
98 + </EmptyState>
99 + ) : (
100 + <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
101 + <div className="min-w-0">
102 + <GraphExplorer nodes={nodes} edges={edges} rootId={rootId} />
103 + {capped && <Note className="mt-2">The neighbourhood is larger than {CAP} nodes; only the first {CAP} are drawn (+ more not shown). Depth 1 shows direct relations only; use the entity's Relations block for full lists.</Note>}
104 + {depth === 2 && <Note className="mt-1">{fmtInt(secondOrder)} of the edges connect neighbours to each other (second order).</Note>}
105 + </div>
106 + <aside className="min-w-0 space-y-6">
107 + <section>
108 + <p className="eyebrow mb-2">Node types</p>
109 + <ul className="space-y-1 text-sm">
110 + {typeCounts.map(([t, n]) => (
111 + <li key={t} className="flex items-center justify-between gap-2">
112 + <span className="flex items-center gap-2">
113 + <span className="inline-block size-2.5 rounded-full" style={{ background: `var(--type-${TYPE_COLOR_KEY[t] ?? 'tool'})` }} aria-hidden />
114 + {typeLabel(t, n !== 1)}
115 + </span>
116 + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
117 + </li>
118 + ))}
119 + </ul>
120 + </section>
121 + <section>
122 + <p className="eyebrow mb-2">Predicates</p>
123 + <ul className="space-y-1 text-sm">
124 + {predicateCounts.map(([p, n]) => (
125 + <li key={p} className="flex items-center justify-between gap-2">
126 + <span className="text-ink-2">{predicateLabel(p, 'out')} <span className="mono text-[11px] text-ink-3">{p}</span></span>
127 + <span className="tnum text-xs text-ink-3">{fmtInt(n)}</span>
128 + </li>
129 + ))}
130 + </ul>
131 + </section>
132 + </aside>
133 + <section className="min-w-0 lg:col-span-2">
134 + <p className="eyebrow mb-2">Direct relations, as a list</p>
135 + {groups.size === 0 ? (
136 + <p className="text-sm text-ink-3">No direct relations for the root node in this graph.</p>
137 + ) : (
138 + <dl className="kv">
139 + {[...groups.entries()].map(([key, items]) => {
140 + const [pred, dir] = key.split('|') as [string, 'out' | 'in'];
141 + return (
142 + <div key={key}>
143 + <dt>{predicateLabel(pred, dir)} <span className="tnum text-ink-3">{fmtInt(items.length)}</span></dt>
144 + <dd className="flex flex-wrap gap-x-3 gap-y-1">
145 + {items.map(({ node }) => (
146 + <span key={node.id} className="inline-flex items-center gap-1.5">
147 + <EntityBadge type={node.entity_type} small />
148 + <EntityLink e={node} />
149 + </span>
150 + ))}
151 + </dd>
152 + </div>
153 + );
154 + })}
155 + </dl>
156 + )}
157 + </section>
158 + </div>
159 + )}
160 + </div>
161 + </Container>
162 + );
163 +}
added apps/web/src/app/hardware/fit/page.tsx +166 −0
@@ -0,0 +1,166 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
5 +import { CONTEXTS, FitForm, QUANTS } from '@/components/hardware/fit-form';
6 +import { Estimated, OpennessBadge } from '@/components/ui/badges';
7 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
8 +import { EntityLink } from '@/components/ui/entity';
9 +import { Container, Note, PageHeader } from '@/components/ui/section';
10 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
11 +import { api, ApiError } from '@/lib/api';
12 +import { fmtGb, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
13 +import { routes } from '@/lib/site';
14 +import type { HardwareFit } from '@/lib/types';
15 +
16 +export const revalidate = 600;
17 +
18 +type SP = Record<string, string | undefined>;
19 +
20 +function parseInputs(sp: SP): { memory: number | null; quant: string; context: number; fitsOnly: boolean } {
21 + const custom = num(sp.memory_custom);
22 + const preset = num(sp.memory_gb);
23 + const memory = custom !== null && custom > 0 ? custom : preset !== null && preset > 0 ? preset : null;
24 + const quant = QUANTS.some((q) => q.value === sp.quant) ? (sp.quant as string) : '4bit';
25 + const ctx = num(sp.context);
26 + const context = ctx !== null && ctx > 0 ? Math.round(ctx) : 8192;
27 + return { memory, quant, context, fitsOnly: sp.fits === '1' };
28 +}
29 +
30 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
31 + const { memory, quant, context } = parseInputs(await searchParams);
32 + const base = 'Hardware fit — which models run on my machine? (estimated)';
33 + const title = memory ? `Models estimated to fit ${fmtGb(memory)} at ${quant}, ${fmtTokens(context)} context` : base;
34 + return {
35 + title,
36 + description: 'Estimate which AI models fit a given amount of device memory at 4-bit, 8-bit or fp16 with a chosen context window. Estimates only — bytes per parameter plus a KV-cache allowance, never a measurement.',
37 + alternates: { canonical: memory ? routes.hardwareFit({ memory_gb: memory, quant, context }) : '/hardware/fit' },
38 + };
39 +}
40 +
41 +export default async function HardwareFitPage({ searchParams }: { searchParams: Promise<SP> }) {
42 + const sp = await searchParams;
43 + const { memory, quant, context, fitsOnly } = parseInputs(sp);
44 + let res: HardwareFit | null = null;
45 + let error: string | null = null;
46 + if (memory) {
47 + try {
48 + res = await api.hardwareFit({ memory_gb: memory, quant, context, limit: 200 });
49 + } catch (e) {
50 + error = e instanceof ApiError ? e.detail ?? e.message : 'API unreachable';
51 + }
52 + }
53 + const items = res ? (fitsOnly ? res.items.filter((i) => i.fits) : res.items) : [];
54 + const fits = num(res?.counts?.fits) ?? res?.items.filter((i) => i.fits).length ?? null;
55 + const evaluated = num(res?.counts?.evaluated) ?? res?.items.length ?? null;
56 + const self = (patch: { fits?: boolean }) => routes.hardwareFit({ memory_gb: memory ?? undefined, quant, context }) + (patch.fits ? '&fits=1' : '');
57 + const ctxLabel = CONTEXTS.find((c) => Number(c.value) === context)?.label ?? `${fmtTokens(context)} tokens`;
58 +
59 + return (
60 + <Container wide>
61 + <PageHeader
62 + eyebrow={
63 + <>
64 + <Link href={routes.hardware()} className="hover:text-ink">Hardware</Link> <span aria-hidden>/</span> Fit tool
65 + </>
66 + }
67 + title="What fits my machine?"
68 + lede="Pick a device memory size, a quantization and a context window: the atlas estimates each model's memory need from its parameter count. Every figure here is an estimate, never a measurement."
69 + aside={<Estimated />}
70 + >
71 + <FitForm memory={memory ? String(memory) : undefined} quant={quant} context={String(context)} className="mt-6" />
72 + </PageHeader>
73 +
74 + <div className="pb-16">
75 + {!memory ? (
76 + <EmptyState title="Choose a memory size to start">
77 + Try <Link href={routes.hardwareFit({ memory_gb: 32, quant: '4bit', context: 8192 })} className="link">32 GB · 4-bit · 8k</Link>, <Link href={routes.hardwareFit({ memory_gb: 80, quant: '8bit', context: 32768 })} className="link">80 GB · 8-bit · 32k</Link> or <Link href={routes.hardwareFit({ memory_gb: 192, quant: 'fp16', context: 131072 })} className="link">192 GB · fp16 · 128k</Link>.
78 + </EmptyState>
79 + ) : !res ? (
80 + <Unavailable what="Hardware fit" reason={error ?? undefined} />
81 + ) : (
82 + <>
83 + <div className="mb-5 border-y border-rule py-3">
84 + <p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
85 + <Estimated />
86 + <span className="font-semibold text-ink">All figures are estimates, not measurements.</span>
87 + <span className="tnum text-ink-2">
88 + {fmtGb(memory)} · {QUANTS.find((q) => q.value === quant)?.label.split(' (')[0] ?? quant} · {ctxLabel}
89 + </span>
90 + {fits !== null && evaluated !== null && (
91 + <span className="tnum text-ink-3">
92 + <span className="font-medium text-positive">{fmtInt(fits)}</span> of {fmtInt(evaluated)} models with a known parameter count fit
93 + </span>
94 + )}
95 + </p>
96 + <ul className="mt-2 list-disc space-y-0.5 pl-5 text-xs leading-relaxed text-ink-3">
97 + {res.assumptions.map((a) => (
98 + <li key={a}>{a}</li>
99 + ))}
100 + {res.assumptions.length === 0 && <li>Assumptions unavailable from the API.</li>}
101 + </ul>
102 + <p className="mt-2 flex flex-wrap items-center gap-3 text-xs">
103 + {fitsOnly ? (
104 + <Link href={self({})} className="link">Show all evaluated models</Link>
105 + ) : (
106 + <Link href={self({ fits: true })} className="link">Show only fitting models</Link>
107 + )}
108 + <Link href="/methodology#estimates" className="text-ink-3 hover:text-ink">Method →</Link>
109 + </p>
110 + </div>
111 +
112 + {items.length === 0 ? (
113 + <EmptyState title={fitsOnly ? 'No model is estimated to fit' : 'No models to evaluate'}>{fitsOnly ? 'Try a larger memory size or a lower-precision quantization.' : 'Models without a sourced parameter count are not estimated.'}</EmptyState>
114 + ) : (
115 + <DataTable caption="Estimated hardware fit">
116 + <thead>
117 + <tr>
118 + <Th>Model</Th>
119 + <Th num>Params</Th>
120 + <Th>Quant</Th>
121 + <Th num>Est. memory</Th>
122 + <Th num>Headroom</Th>
123 + <Th>Fits</Th>
124 + <Th className="w-24"><span className="sr-only">Compare</span></Th>
125 + </tr>
126 + </thead>
127 + <tbody>
128 + {items.length === 0 && <EmptyRow cols={7} />}
129 + {items.map((r) => {
130 + const openness = typeof r.model.attributes?.openness === 'string' ? r.model.attributes.openness : null;
131 + return (
132 + <tr key={`${r.model.id}-${r.quantization}`}>
133 + <Td primary>
134 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
135 + <EntityLink e={r.model} />
136 + {openness && <OpennessBadge openness={openness} />}
137 + </span>
138 + {r.model.organization && <span className="block text-xs text-ink-3">{r.model.organization.name}</span>}
139 + {r.note && <span className="block text-xs text-ink-3">{r.note}</span>}
140 + </Td>
141 + <Td num label="Params" className="tnum">{fmtParams(r.parameter_count)}</Td>
142 + <Td label="Quant" className="mono text-xs text-ink-2">{r.quantization}</Td>
143 + <Td num label="Est. memory" className="tnum">{fmtGb(r.estimated_memory_gb, 1)}</Td>
144 + <Td num label="Headroom" className={r.fits ? 'tnum text-positive' : 'tnum text-danger'}>
145 + {num(r.headroom_gb) === null ? '—' : `${r.headroom_gb >= 0 ? '+' : '−'}${fmtGb(Math.abs(r.headroom_gb), 1)}`}
146 + </Td>
147 + <Td label="Fits" className={r.fits ? 'font-medium text-positive' : 'text-ink-3'}>{r.fits ? '✓ fits' : '✗ too large'}</Td>
148 + <Td className="text-right">
149 + <CompareButton e={r.model} size="sm" />
150 + </Td>
151 + </tr>
152 + );
153 + })}
154 + </tbody>
155 + </DataTable>
156 + )}
157 + <Note className="mt-3">
158 + Headroom = device memory − 2 GB reserve − estimated need. Sorted by the API (fitting models first). Compare the models you shortlist with the + buttons. Devices with these memory sizes: <Link href={routes.hardware()} className="link">hardware listing</Link>.
159 + </Note>
160 + </>
161 + )}
162 + </div>
163 + <CompareTrayBar />
164 + </Container>
165 + );
166 +}
modified apps/web/src/app/hardware/page.tsx +29 −2
@@ -1,7 +1,10 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 5 import { GenericListing } from '@/components/listing/generic-listing';
4 6 import { api } from '@/lib/api';
7 +import { routes } from '@/lib/site';
5 8
6 9 export const metadata: Metadata = { title: 'AI hardware — GPUs, accelerators, memory and bandwidth', description: 'Accelerators and devices for training and inference: memory, bandwidth, TDP, runtimes — and which models are estimated to fit.', alternates: { canonical: '/hardware' } };
7 10 export const revalidate = 600;
@@ -9,10 +12,34 @@ export const revalidate = 600;
9 12 export default async function HardwarePage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
10 13 const sp = await searchParams;
11 14 return (
12 <GenericListing type="hardware" basePath="/hardware" eyebrow="Hardware" title="Hardware" lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers." searchParams={sp} fetch={(q) => api.hardware(q)} sorts={[{ value: 'memory', label: 'Memory' }, { value: 'name', label: 'Name' }, { value: 'updated', label: 'Recently updated' }]} extraFields={[{ kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' }, { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' }]}>
15 + <GenericListing
16 + type="hardware"
17 + basePath="/hardware"
18 + eyebrow="Hardware"
19 + title="Hardware"
20 + lede="GPUs, accelerators and consumer devices with the memory and bandwidth figures published by their manufacturers."
21 + searchParams={sp}
22 + fetch={(q) => api.hardware(q)}
23 + sorts={[
24 + { value: 'memory', label: 'Memory' },
25 + { value: 'name', label: 'Name' },
26 + { value: 'updated', label: 'Recently updated' },
27 + ]}
28 + extraFields={[
29 + { kind: 'text', name: 'kind', label: 'Kind', value: sp.kind, placeholder: 'gpu, accelerator, soc…' },
30 + { kind: 'text', name: 'manufacturer', label: 'Manufacturer', value: sp.manufacturer, placeholder: 'NVIDIA, Apple…' },
31 + ]}
32 + rowTrailing={(e) => <CompareButton e={e} size="sm" />}
33 + headerAside={
34 + <Link href={routes.hardwareFit()} className="inline-flex h-10 items-center border border-rule-strong px-3 text-sm font-medium text-ink hover:border-accent hover:text-accent">
35 + What fits my machine? →
36 + </Link>
37 + }
38 + >
13 39 <p className="mb-6 text-sm text-ink-2">
14 Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>.
40 + Each device page lists the models estimated to fit its memory per quantization — labelled as estimates, method on <Link href="/methodology#estimates" className="link">/methodology</Link>. Use <Link href={routes.hardwareFit()} className="link">the fit tool</Link> for any memory size.
15 41 </p>
42 + <CompareTrayBar />
16 43 </GenericListing>
17 44 );
18 45 }
added apps/web/src/app/icon-192.png/render.tsx +26 −0
@@ -0,0 +1,26 @@
1 +import { ImageResponse } from 'next/og';
2 +
3 +/**
4 + * Square PNG app icon (manifest 192 / 512) drawn from the AI Atlas mark — same artwork as `apple-icon.tsx`.
5 + * Colours are the dark canvas / ink / accent tokens; raw hex is unavoidable in ImageResponse (no CSS variables).
6 + */
7 +export function renderIcon(px: number): ImageResponse {
8 + const mark = Math.round(px * 0.72);
9 + return new ImageResponse(
10 + (
11 + <div style={{ width: px, height: px, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0b0d11' }}>
12 + <svg width={mark} height={mark} viewBox="0 0 32 32" fill="none">
13 + <circle cx="16" cy="16" r="13" stroke="#e9ebf0" strokeWidth="1.6" />
14 + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.7" />
15 + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke="#e9ebf0" strokeWidth="1.2" opacity="0.55" />
16 + <circle cx="16" cy="16" r="2.4" fill="#6d95ff" />
17 + <circle cx="9.4" cy="9.5" r="1.7" fill="#e9ebf0" />
18 + <circle cx="22.6" cy="9.5" r="1.7" fill="#e9ebf0" />
19 + <circle cx="10.8" cy="24.4" r="1.7" fill="#e9ebf0" />
20 + <circle cx="23.4" cy="21.6" r="1.7" fill="#e9ebf0" />
21 + </svg>
22 + </div>
23 + ),
24 + { width: px, height: px, headers: { 'cache-control': 'public, max-age=86400, s-maxage=86400' } },
25 + );
26 +}
added apps/web/src/app/icon-192.png/route.tsx +9 −0
@@ -0,0 +1,9 @@
1 +import { renderIcon } from './render';
2 +
3 +export const runtime = 'nodejs';
4 +export const dynamic = 'force-static';
5 +
6 +/** 192×192 PNG icon for the web app manifest. */
7 +export function GET() {
8 + return renderIcon(192);
9 +}
added apps/web/src/app/icon-512.png/route.tsx +9 −0
@@ -0,0 +1,9 @@
1 +import { renderIcon } from '../icon-192.png/render';
2 +
3 +export const runtime = 'nodejs';
4 +export const dynamic = 'force-static';
5 +
6 +/** 512×512 PNG icon for the web app manifest (also used as the maskable icon). */
7 +export function GET() {
8 + return renderIcon(512);
9 +}
added apps/web/src/app/manifest.ts +37 −0
@@ -0,0 +1,37 @@
1 +import type { MetadataRoute } from 'next';
2 +import { DESCRIPTION, SITE_NAME } from '@/lib/site';
3 +
4 +/**
5 + * Web app manifest (PWA basics). Colours mirror the light theme tokens in globals.css (`--canvas`), matching the
6 + * `themeColor` declared in the root layout. PNG icons are rendered on demand by `/icon-192.png` and `/icon-512.png`.
7 + */
8 +export default function manifest(): MetadataRoute.Manifest {
9 + return {
10 + name: SITE_NAME,
11 + short_name: SITE_NAME,
12 + description: DESCRIPTION,
13 + id: '/',
14 + start_url: '/',
15 + scope: '/',
16 + display: 'standalone',
17 + orientation: 'any',
18 + background_color: '#f6f6f3',
19 + theme_color: '#f6f6f3',
20 + lang: 'en',
21 + dir: 'ltr',
22 + categories: ['reference', 'productivity'],
23 + icons: [
24 + { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
25 + { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
26 + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
27 + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
28 + { src: '/apple-icon', sizes: '180x180', type: 'image/png', purpose: 'any' },
29 + ],
30 + shortcuts: [
31 + { name: 'Models', short_name: 'Models', description: 'Every AI model with parameters, context, openness and prices', url: '/models', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
32 + { name: 'Changes', short_name: 'Changes', description: 'What changed in AI today', url: '/changes', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
33 + { name: 'Compare', short_name: 'Compare', description: 'Compare 2–6 entities side by side', url: '/compare', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
34 + { name: 'Prices', short_name: 'Prices', description: 'AI Price Index — USD per 1M tokens', url: '/prices', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }] },
35 + ],
36 + };
37 +}
modified apps/web/src/app/models/(list)/page.tsx +12 −4
@@ -1,5 +1,7 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 5 import { ActiveFilters, type FacetGroup, Facets, FilterBar, ListingLayout } from '@/components/listing/filters';
4 6 import { OpennessBadge, StatusBadge } from '@/components/ui/badges';
5 7 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
@@ -122,11 +124,16 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi
122 124 return (
123 125 <tr key={m.id}>
124 126 <Td primary>
125 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
126 <EntityLink e={m} />
127 <StatusBadge status={m.status !== 'active' ? m.status : null} />
127 + <div className="flex items-start justify-between gap-2">
128 + <div className="min-w-0">
129 + <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
130 + <EntityLink e={m} />
131 + <StatusBadge status={m.status !== 'active' ? m.status : null} />
132 + </div>
133 + {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>}
134 + </div>
135 + <CompareButton e={m} size="sm" className="mt-0.5" />
128 136 </div>
129 {typeof a.family === 'string' && <span className="block text-xs text-ink-3">{a.family}</span>}
130 137 </Td>
131 138 <Td label="Organization" className="text-ink-2">
132 139 {m.organization ? (
@@ -156,6 +163,7 @@ export default async function ModelsPage({ searchParams }: { searchParams: Promi
156 163 )}
157 164 </ListingLayout>
158 165 </div>
166 + <CompareTrayBar />
159 167 </Container>
160 168 );
161 169 }
added apps/web/src/app/models/[slug]/opengraph-image.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import { ImageResponse } from 'next/og';
2 +import { api, safe } from '@/lib/api';
3 +import { fmtDate, fmtParams, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
4 +import { OPENNESS_LABELS, routes, SITE_NAME } from '@/lib/site';
5 +import type { EntityDetail } from '@/lib/types';
6 +import { Eyebrow, Facts, Fallback, Frame, INK2, Title } from '@/components/brand/og';
7 +
8 +export const runtime = 'nodejs';
9 +export const alt = `Model on ${SITE_NAME}`;
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +function modelFacts(d: EntityDetail): [string, string][] {
14 + const a = d.attributes ?? {};
15 + const out: [string, string][] = [];
16 + if (num(a.parameter_count) !== null) out.push(['Parameters', fmtParams(a.parameter_count)]);
17 + if (num(a.context_length) !== null) out.push(['Context', `${fmtTokens(a.context_length)} tokens`]);
18 + const inputs = (d.prices ?? []).map((p) => num(p.input_per_mtok)).filter((v): v is number => v !== null);
19 + if (inputs.length) {
20 + const best = Math.min(...inputs);
21 + out.push(['Best input / 1M', best >= 1 ? `$${best.toFixed(2)}` : fmtUsdPerM(best)]);
22 + }
23 + if (typeof a.release_date === 'string') out.push(['Released', fmtDate(a.release_date)]);
24 + if (typeof a.openness === 'string') out.push(['Openness', OPENNESS_LABELS[a.openness] ?? a.openness]);
25 + if (typeof a.license === 'string') out.push(['License', a.license.slice(0, 18)]);
26 + return out.slice(0, 3);
27 +}
28 +
29 +/** Per-model Open Graph image: brand, "Model" eyebrow, name, organization, three key attributes, canonical URL. */
30 +export default async function ModelOgImage({ params }: { params: Promise<{ slug: string }> }) {
31 + const { slug } = await params;
32 + const d = await safe(api.entityOfType('models', slug));
33 + if (!d) return new ImageResponse(<Fallback label="Model" />, { ...size });
34 + const facts = modelFacts(d);
35 + return new ImageResponse(
36 + (
37 + <Frame footer={`www.ai-atlas.co${routes.entity(d)}`}>
38 + <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
39 + <Eyebrow>{d.organization ? `Model · ${d.organization.name}` : 'Model'}</Eyebrow>
40 + <Title>{d.name}</Title>
41 + {facts.length > 0 ? <Facts items={facts} /> : <div style={{ display: 'flex', fontSize: 24, color: INK2 }}>{d.description ? d.description.slice(0, 120) : 'Specifications, prices, benchmarks, lineage and sources.'}</div>}
42 + </div>
43 + </Frame>
44 + ),
45 + { ...size },
46 + );
47 +}
modified apps/web/src/app/models/[slug]/page.tsx +5 −4
@@ -1,21 +1,22 @@
1 1 import type { Metadata } from 'next';
2 2 import { 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 { routes } from '@/lib/site';
7 7
8 type Params = { params: Promise<{ slug: string }> };
8 +type Params = { params: Promise<{ slug: string }>; searchParams: Promise<EntityPageParams> };
9 9
10 10 export async function generateMetadata({ params }: Params): Promise<Metadata> {
11 11 const { slug } = await params;
12 12 return entityMetadata('models', slug);
13 13 }
14 14
15 export default async function ModelPage({ params }: Params) {
15 +export default async function ModelPage({ params, searchParams }: Params) {
16 16 const { slug } = await params;
17 + const sp = await searchParams;
17 18 const d = await loadEntity('models', slug);
18 19 if (d.slug !== slug) permanentRedirect(routes.entity(d));
19 20 const related = await safe(api.entityRelated(d.slug, 10));
20 return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} />;
21 + return <EntityPage d={d} canonical={routes.entity(d)} related={related?.items} asof={sp.asof} historyProperty={sp.property} />;
21 22 }
modified apps/web/src/app/not-found.tsx +25 −11
@@ -1,22 +1,36 @@
1 +import { Search } from 'lucide-react';
1 2 import type { Metadata } from 'next';
2 3 import Link from 'next/link';
3 import { Container } from '@/components/ui/section';
4 import { routes } from '@/lib/site';
4 +import { Container, Note } from '@/components/ui/section';
5 +import { PUBLIC_API_BASE, routes } from '@/lib/site';
5 6
6 7 export const metadata: Metadata = { title: 'Not found', robots: { index: false } };
7 8
9 +/** 404 for the whole site (and for entity pages whose slug/type does not resolve). Search is the way back. */
8 10 export default function NotFound() {
11 + const btn = 'inline-flex h-11 items-center border border-rule px-3.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink';
9 12 return (
10 <Container className="py-20 md:py-28">
11 <p className="eyebrow">404</p>
12 <h1 className="display mt-2 text-3xl md:text-5xl">This page is not on the map.</h1>
13 <p className="mt-4 max-w-xl text-ink-2">The entity may have been merged into another record, renamed, or never existed. Search is the fastest way back.</p>
14 <div className="mt-6 flex flex-wrap gap-3 text-sm">
15 <Link href={routes.home()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Home</Link>
16 <Link href={routes.models()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Models</Link>
17 <Link href={routes.explore()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Explore</Link>
18 <Link href={routes.changes()} className="border border-rule px-3 py-2 text-ink-2 hover:text-ink">Changes</Link>
13 + <Container className="py-16 md:py-24">
14 + <p className="eyebrow">404 · Not on the map</p>
15 + <h1 className="display mt-2 text-3xl md:text-5xl">We have no record at this address.</h1>
16 + <p className="mt-4 max-w-xl text-[15px] leading-relaxed text-ink-2">
17 + The entity may have been merged into another record (its old slug then redirects), renamed by its source, or never crawled. Nothing is invented to fill the gap — search the atlas instead.
18 + </p>
19 + <form action="/search" method="get" role="search" className="mt-6 flex max-w-xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">
20 + <label htmlFor="nf-q" className="sr-only">Search AI Atlas</label>
21 + <Search className="my-auto ml-3 size-4 shrink-0 text-ink-3" aria-hidden />
22 + <input id="nf-q" name="q" type="search" placeholder="Search models, companies, papers, benchmarks…" className="h-11 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" spellCheck={false} />
23 + <button type="submit" className="bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">Search</button>
24 + </form>
25 + <div className="mt-6 flex flex-wrap gap-2 text-sm">
26 + <Link href={routes.home()} className={btn}>Home</Link>
27 + <Link href={routes.models()} className={btn}>Models</Link>
28 + <Link href={routes.explore()} className={btn}>Explore all types</Link>
29 + <Link href={routes.changes()} className={btn}>Changes</Link>
19 30 </div>
31 + <Note className="mt-8 max-w-xl">
32 + Canonical URLs are <span className="mono">/models/&lt;slug&gt;</span>, <span className="mono">/companies/&lt;slug&gt;</span>, <span className="mono">/papers/&lt;slug&gt;</span>… — a slug under the wrong type is a 404, not a duplicate page. Programmatic lookup: <a href={`${PUBLIC_API_BASE}/search/suggest?q=claude`} className="link mono">{PUBLIC_API_BASE.replace(/^https?:\/\/www\./, '')}/search/suggest?q=…</a>
33 + </Note>
20 34 </Container>
21 35 );
22 36 }
modified apps/web/src/app/papers/page.tsx +92 −8
@@ -1,23 +1,107 @@
1 1 import type { Metadata } from 'next';
2 import { GenericListing } from '@/components/listing/generic-listing';
2 +import Link from 'next/link';
3 +import { Dash, list, str, TypedListing } from '@/components/listing/typed-listing';
4 +import { Chip } from '@/components/ui/badges';
5 +import { EntityLink, QualityMark } from '@/components/ui/entity';
3 6 import { api } from '@/lib/api';
7 +import { fmtDate } from '@/lib/format';
8 +import { routes } from '@/lib/site';
4 9
5 export const metadata: Metadata = { title: 'AI research papers', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe.', alternates: { canonical: '/papers' } };
10 +export const metadata: Metadata = { title: 'AI research papers — authors, dates, categories', description: 'Research papers in the atlas, linked to the models, organizations and benchmarks they describe. Authors, arXiv categories and publication dates as stated by their sources.', alternates: { canonical: '/papers' } };
6 11 export const revalidate = 300;
7 12
8 13 export default async function PapersPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
9 14 const sp = await searchParams;
10 15 return (
11 <GenericListing
12 type="paper"
13 basePath="/papers"
14 eyebrow="Research"
16 + <TypedListing
15 17 title="Papers"
18 + eyebrow="Research"
16 19 lede="Publications linked to models, labs and benchmarks. Authors, venues and abstracts come from arXiv and publisher pages."
20 + basePath="/papers"
17 21 searchParams={sp}
18 22 fetch={(q) => api.papers(q)}
19 sorts={[{ value: 'published', label: 'Recently published' }, { value: 'updated', label: 'Recently updated' }]}
20 extraFields={[{ kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' }, { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' }]}
23 + sorts={[
24 + { value: 'published', label: 'Recently published' },
25 + { value: 'updated', label: 'Recently updated' },
26 + ]}
27 + filters={[
28 + { kind: 'text', name: 'category', label: 'arXiv category', value: sp.category, placeholder: 'cs.CL, cs.LG…' },
29 + { kind: 'text', name: 'since', label: 'Published since', value: sp.since, placeholder: 'YYYY-MM-DD' },
30 + ...(sp.org ? [{ kind: 'hidden' as const, name: 'org', value: sp.org }] : []),
31 + ]}
32 + columns={[
33 + {
34 + key: 'title',
35 + label: 'Title',
36 + primary: true,
37 + render: (e) => (
38 + <>
39 + <EntityLink e={e} />
40 + {str(e.attributes?.arxiv_id) && <span className="mono ml-2 text-[11px] text-ink-3">arXiv:{str(e.attributes.arxiv_id)}</span>}
41 + </>
42 + ),
43 + },
44 + {
45 + key: 'authors',
46 + label: 'Authors',
47 + className: 'max-w-[22rem] text-ink-2',
48 + render: (e) => {
49 + const a = list(e.attributes?.authors);
50 + if (!a.length) return <Dash />;
51 + return (
52 + <span className="text-sm">
53 + {a.slice(0, 3).join(', ')}
54 + {a.length > 3 && <span className="tnum text-ink-3"> +{a.length - 3}</span>}
55 + </span>
56 + );
57 + },
58 + },
59 + { key: 'published', label: 'Published', className: 'tnum text-ink-2 whitespace-nowrap', render: (e) => (str(e.attributes?.published_at) ? fmtDate(str(e.attributes.published_at)) : <Dash />) },
60 + {
61 + key: 'categories',
62 + label: 'Categories',
63 + render: (e) => {
64 + const cats = list(e.attributes?.categories);
65 + const primary = str(e.attributes?.primary_category);
66 + const all = [...new Set([...(primary ? [primary] : []), ...cats])].slice(0, 4);
67 + if (!all.length) return <Dash />;
68 + return (
69 + <span className="flex flex-wrap gap-1">
70 + {all.map((c) => (
71 + <Link key={c} href={`/papers?category=${encodeURIComponent(c)}`} className="hover:opacity-80">
72 + <Chip tone={c === primary ? 'accent' : 'neutral'} className="mono">{c}</Chip>
73 + </Link>
74 + ))}
75 + </span>
76 + );
77 + },
78 + },
79 + {
80 + key: 'org',
81 + label: 'Organization / venue',
82 + className: 'text-ink-2',
83 + render: (e) => {
84 + const venue = str(e.attributes?.venue);
85 + if (!e.organization && !venue) return <Dash />;
86 + return (
87 + <>
88 + {e.organization && (
89 + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
90 + {e.organization.name}
91 + </Link>
92 + )}
93 + {venue && <span className="block text-xs text-ink-3">{venue}</span>}
94 + </>
95 + );
96 + },
97 + },
98 + { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
99 + ]}
100 + note={
101 + <>
102 + Author lists and categories are copied from the paper's own metadata (arXiv, publisher). Each paper page shows the abstract, related models and every source snapshot.
103 + </>
104 + }
21 105 />
22 106 );
23 107 }
added apps/web/src/app/prices/loading.tsx +32 −0
@@ -0,0 +1,32 @@
1 +import { Container } from '@/components/ui/section';
2 +
3 +export default function Loading() {
4 + return (
5 + <Container wide className="pb-16 pt-7 md:pt-10" aria-busy="true">
6 + <div className="h-4 w-16 animate-pulse bg-surface-2" />
7 + <div className="mt-3 h-9 w-64 max-w-full animate-pulse bg-surface-2" />
8 + <div className="mt-6 flex gap-1.5">
9 + {Array.from({ length: 4 }).map((_, i) => (
10 + <div key={i} className="h-9 w-20 animate-pulse bg-surface-2" />
11 + ))}
12 + </div>
13 + <div className="mt-10 grid grid-cols-2 gap-x-6 border-y border-rule md:grid-cols-5">
14 + {Array.from({ length: 5 }).map((_, i) => (
15 + <div key={i} className="py-4">
16 + <div className="h-3 w-20 animate-pulse bg-surface-2" />
17 + <div className="mt-2 h-7 w-24 animate-pulse bg-surface-2" />
18 + </div>
19 + ))}
20 + </div>
21 + <div className="mt-6 h-[240px] animate-pulse bg-surface-2" />
22 + <ul className="mt-10 border-t border-rule">
23 + {Array.from({ length: 8 }).map((_, i) => (
24 + <li key={i} className="border-b border-rule py-3">
25 + <div className="h-4 w-2/3 animate-pulse bg-surface-2" />
26 + </li>
27 + ))}
28 + </ul>
29 + <p className="sr-only">Loading prices…</p>
30 + </Container>
31 + );
32 +}
added apps/web/src/app/prices/page.tsx +228 −0
@@ -0,0 +1,228 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Legend, LineChart, type Series } from '@/components/charts/charts';
4 +import { FilterBar } from '@/components/listing/filters';
5 +import { PriceMovers } from '@/components/prices/movers';
6 +import { ScaleToggle } from '@/components/prices/scale-toggle';
7 +import { ChipRow } from '@/components/timeline/chip-row';
8 +import { Chip } from '@/components/ui/badges';
9 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
10 +import { EntityLink } from '@/components/ui/entity';
11 +import { Pagination, withParams } from '@/components/ui/pagination';
12 +import { SourceCell } from '@/components/ui/provenance';
13 +import { Container, Note, PageHeader, Section, Stat, StatGrid } from '@/components/ui/section';
14 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
15 +import { api, ApiError, safe } from '@/lib/api';
16 +import { fmtAgo, fmtDate, fmtInt, fmtTokens, fmtUsdPerM, num } from '@/lib/format';
17 +import { routes, SITE_NAME } from '@/lib/site';
18 +import type { Page, Price } from '@/lib/types';
19 +
20 +export const revalidate = 300;
21 +
22 +type SP = Record<string, string | undefined>;
23 +const LIMIT = 100;
24 +const DAYS = [30, 90, 180, 365];
25 +const SORTS = ['input', 'output', 'model', 'provider', 'observed'] as const;
26 +type Sort = (typeof SORTS)[number];
27 +const KEYS = ['days', 'scale', 'sort', 'provider', 'model', 'offset'] as const;
28 +
29 +function pick(sp: SP) {
30 + const cur: Record<string, string | undefined> = {};
31 + for (const k of KEYS) if (sp[k]) cur[k] = sp[k];
32 + const days = DAYS.includes(Number(cur.days)) ? Number(cur.days) : 180;
33 + const sort: Sort = (SORTS as readonly string[]).includes(cur.sort ?? '') ? (cur.sort as Sort) : 'input';
34 + return { cur, days, sort, offset: Math.max(0, Number(cur.offset) || 0), scale: cur.scale === 'log' ? ('log' as const) : ('linear' as const) };
35 +}
36 +
37 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
38 + const { cur, days } = pick(await searchParams);
39 + const title = cur.provider || cur.model ? `AI Price Index — ${[cur.provider, cur.model].filter(Boolean).join(' · ')}` : 'AI Price Index — USD per 1M tokens across providers';
40 + return {
41 + title,
42 + description: `Median and minimum published input/output prices per 1M tokens across every provider AI Atlas tracks, over the last ${days} days, plus recent price changes and every current offer with its source.`,
43 + alternates: { canonical: routes.prices() },
44 + openGraph: { title: `${title} | ${SITE_NAME}` },
45 + robots: cur.provider || cur.model || cur.offset ? { index: false, follow: true } : undefined,
46 + };
47 +}
48 +
49 +/** `/prices` 404s when the model/provider slug is unknown — keep that distinction instead of a generic "unavailable". */
50 +async function loadPrices(query: Record<string, string | number | undefined>): Promise<{ page: Page<Price> | null; notFound: boolean }> {
51 + try {
52 + return { page: await api.prices(query), notFound: false };
53 + } catch (e) {
54 + return { page: null, notFound: e instanceof ApiError && e.notFound };
55 + }
56 +}
57 +
58 +export default async function PricesPage({ searchParams }: { searchParams: Promise<SP> }) {
59 + const sp = await searchParams;
60 + const { cur, days, sort, offset, scale } = pick(sp);
61 + const [index, providers, priced] = await Promise.all([safe(api.priceIndex(days)), safe(api.providers()), loadPrices({ sort, provider: cur.provider, model: cur.model, limit: LIMIT, offset })]);
62 + const href = (patch: Record<string, string | number | undefined | null>) => withParams('/prices', cur, patch);
63 +
64 + // ---- index: latest populated day + chart series (null days skipped)
65 + const series = index?.series ?? [];
66 + const populated = series.filter((p) => num(p.median_input) !== null || num(p.median_output) !== null);
67 + const latest = populated.at(-1) ?? null;
68 + const first = populated[0] ?? null;
69 + const toSeries = (name: string, field: 'median_input' | 'median_output' | 'min_input', color: string): Series => ({ name, color, points: series.flatMap((p) => (num(p[field]) === null ? [] : [{ x: new Date(`${p.day}T00:00:00Z`), y: num(p[field]) as number }])) });
70 + const chartSeries = [toSeries('Median input', 'median_input', 'var(--series-1)'), toSeries('Median output', 'median_output', 'var(--series-2)'), toSeries('Cheapest input', 'min_input', 'var(--series-7)')].filter((s) => s.points.length > 0);
71 + const chartPoints = chartSeries.reduce((n, s) => n + s.points.length, 0);
72 + const delta = (field: 'median_input' | 'median_output') => {
73 + const a = num(first?.[field]);
74 + const b = num(latest?.[field]);
75 + if (a === null || b === null || a === 0 || first === latest) return undefined;
76 + const pct = ((b - a) / a) * 100;
77 + return { value: `${pct > 0 ? '+' : ''}${pct.toFixed(1)}%`, tone: pct < 0 ? ('positive' as const) : pct > 0 ? ('negative' as const) : ('neutral' as const) };
78 + };
79 +
80 + // ---- table: cheapest input per model within the shown rows
81 + const page = priced.page;
82 + const rows = page?.items ?? [];
83 + const minByModel = new Map<string, number>();
84 + for (const r of rows) {
85 + const v = num(r.input_per_mtok);
86 + if (v === null) continue;
87 + const m = minByModel.get(r.model.slug);
88 + if (m === undefined || v < m) minByModel.set(r.model.slug, v);
89 + }
90 + // "cheapest" is only meaningful when a model is offered by more than one provider among the shown rows
91 + const providersByModel = new Map<string, Set<string>>();
92 + for (const r of rows) {
93 + const s = providersByModel.get(r.model.slug) ?? new Set<string>();
94 + s.add(r.provider.slug);
95 + providersByModel.set(r.model.slug, s);
96 + }
97 + const providerOptions = (providers?.items ?? []).slice().sort((a, b) => a.name.localeCompare(b.name)).map((p) => ({ value: p.slug, label: p.name }));
98 + const providerName = providerOptions.find((o) => o.value === cur.provider)?.label;
99 + const SortTh = ({ s, children, num: n }: { s: Sort; children: React.ReactNode; num?: boolean }) => (
100 + <Th num={n} aria-sort={sort === s ? 'ascending' : undefined}>
101 + <Link href={href({ sort: s === 'input' ? undefined : s, offset: undefined })} className={sort === s ? 'text-ink' : 'hover:text-ink'}>
102 + {children}
103 + {sort === s && <span aria-hidden> ↑</span>}
104 + </Link>
105 + </Th>
106 + );
107 +
108 + return (
109 + <Container wide>
110 + <PageHeader eyebrow="Prices" title="AI Price Index" lede="What a million tokens costs, day by day: the median and cheapest published input/output prices across every provider offer AI Atlas tracks. Prices are quoted as stated on providers' pricing pages; every change is kept." aside={latest ? <p className="tnum text-sm text-ink-3">{fmtInt(latest.offers)} offers · {fmtInt(latest.models)} models · {fmtDate(latest.day)}</p> : undefined}>
111 + <div className="mt-6">
112 + <p className="eyebrow mb-1.5">Window</p>
113 + <ChipRow label="Window" items={DAYS.map((d) => ({ href: href({ days: d === 180 ? undefined : d }), label: `${d} days`, active: days === d }))} />
114 + </div>
115 + </PageHeader>
116 +
117 + {/* ------------------------------------------------------------------------------------------------ index */}
118 + <Section eyebrow="Index" title={<>Median price per 1M tokens · last {days} days</>} hairline={false}>
119 + {!index ? (
120 + <Unavailable what="Price index" />
121 + ) : !latest ? (
122 + <EmptyState title="No priced offers in this window">Prices appear once a provider pricing page has been crawled. Try a longer window.</EmptyState>
123 + ) : (
124 + <>
125 + <StatGrid cols={5}>
126 + <Stat label="Median input" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_input)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_input')} />
127 + <Stat label="Median output" value={<span className="text-accent-2">{fmtUsdPerM(latest.median_output)}</span>} hint={`per 1M · ${fmtDate(latest.day)}`} delta={delta('median_output')} />
128 + <Stat label="Cheapest input" value={fmtUsdPerM(latest.min_input)} hint={num(latest.max_input) !== null ? `dearest ${fmtUsdPerM(latest.max_input)}` : 'per 1M tokens'} />
129 + <Stat label="Models priced" value={fmtInt(latest.models)} hint="with a current offer" />
130 + <Stat label="Offers" value={fmtInt(latest.offers)} hint="model × provider rows" />
131 + </StatGrid>
132 + <div className="mt-6">
133 + {chartPoints < 2 || populated.length < 2 ? (
134 + <Note>The index has {fmtInt(populated.length)} day{populated.length === 1 ? '' : 's'} of observations so far — a line needs at least two. The chart fills in as daily snapshots accumulate.</Note>
135 + ) : (
136 + <>
137 + <ScaleToggle initial={scale} linear={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens" showDots />} log={<LineChart series={chartSeries} height={240} yFormat={(v) => fmtUsdPerM(v)} yLabel="USD per 1M tokens (log scale)" showDots yScale="log" />} />
138 + <Legend series={chartSeries} className="mt-2" />
139 + </>
140 + )}
141 + </div>
142 + {index.note && <Note className="mt-3">{index.note} {delta('median_input') ? 'Deltas compare the first and last populated day of the window.' : ''}</Note>}
143 + </>
144 + )}
145 + </Section>
146 +
147 + {/* ------------------------------------------------------------------------------------------------ movers */}
148 + <Section eyebrow="Movers" title="Recent price changes" lede="Provider offers whose published price moved, newest first." action={{ href: `${routes.changes()}?category=price`, label: 'All pricing events' }}>
149 + {!index ? <Unavailable what="Price changes" /> : <PriceMovers movers={index.movers ?? []} />}
150 + </Section>
151 +
152 + {/* ------------------------------------------------------------------------------------------------ table */}
153 + <Section eyebrow="Current prices" title={providerName ? `Current offers from ${providerName}` : cur.model ? `Current offers for ${cur.model}` : 'Every current offer'} lede="One row per model × provider, as published. Sort by any column; filter by provider or model slug.">
154 + <FilterBar
155 + action="/prices"
156 + className="mb-5 lg:grid-cols-5"
157 + resetHref={href({ provider: undefined, model: undefined, offset: undefined, sort: undefined })}
158 + fields={[
159 + { kind: 'select', name: 'provider', label: 'Provider', value: cur.provider, options: providerOptions },
160 + { kind: 'text', name: 'model', label: 'Model slug', value: cur.model, placeholder: 'e.g. claude-opus-5' },
161 + ...(cur.days ? [{ kind: 'hidden' as const, name: 'days', value: cur.days }] : []),
162 + ...(cur.scale ? [{ kind: 'hidden' as const, name: 'scale', value: cur.scale }] : []),
163 + ]}
164 + sort={{ value: sort, options: [{ value: 'input', label: 'Cheapest input' }, { value: 'output', label: 'Cheapest output' }, { value: 'model', label: 'Model' }, { value: 'provider', label: 'Provider' }, { value: 'observed', label: 'Recently observed' }] }}
165 + />
166 + {priced.notFound ? (
167 + <EmptyState title={`No ${cur.model ? 'model' : 'provider'} with slug “${cur.model ?? cur.provider}”`}>
168 + Slugs are the last part of an entity URL (<span className="mono">/models/&lt;slug&gt;</span>). <Link href={cur.model ? routes.search(cur.model, 'model') : routes.providers()} className="link">Search instead →</Link>
169 + </EmptyState>
170 + ) : !page ? (
171 + <Unavailable what="Current prices" />
172 + ) : (
173 + <>
174 + <DataTable caption="Current prices per 1M tokens">
175 + <thead>
176 + <tr>
177 + <SortTh s="model">Model</SortTh>
178 + <SortTh s="provider">Provider</SortTh>
179 + <SortTh s="input" num>Input / 1M</SortTh>
180 + <SortTh s="output" num>Output / 1M</SortTh>
181 + <Th num>Cached in</Th>
182 + <Th num>Context</Th>
183 + <SortTh s="observed">Observed</SortTh>
184 + <Th>Source</Th>
185 + </tr>
186 + </thead>
187 + <tbody>
188 + {rows.length === 0 && <EmptyRow cols={8}>No current offers match these filters.</EmptyRow>}
189 + {rows.map((p) => {
190 + const v = num(p.input_per_mtok);
191 + const cheapest = v !== null && minByModel.get(p.model.slug) === v && (providersByModel.get(p.model.slug)?.size ?? 0) > 1;
192 + return (
193 + <tr key={p.id}>
194 + <Td primary>
195 + <EntityLink e={p.model} />
196 + {p.model.organization && <span className="ml-2 text-xs text-ink-3">{p.model.organization.name}</span>}
197 + {p.provider_model_id && p.provider_model_id !== p.model.slug && <span className="mono block text-[11px] text-ink-3">{p.provider_model_id}</span>}
198 + </Td>
199 + <Td label="Provider" className="text-ink-2">
200 + <Link href={href({ provider: p.provider.slug, offset: undefined })} className="hover:text-accent" title="Filter by this provider">
201 + {p.provider.name}
202 + </Link>
203 + <EntityLink e={p.provider} className="ml-1.5 text-xs text-ink-3">↗</EntityLink>
204 + </Td>
205 + <Td num label="Input / 1M" className={cheapest ? 'tnum font-semibold text-accent-2' : 'tnum text-accent-2'}>
206 + {fmtUsdPerM(p.input_per_mtok)}
207 + {cheapest && <Chip tone="accent" className="ml-1.5 align-middle">cheapest</Chip>}
208 + </Td>
209 + <Td num label="Output / 1M" className="tnum text-accent-2">{fmtUsdPerM(p.output_per_mtok)}</Td>
210 + <Td num label="Cached in" className="tnum text-ink-2">{fmtUsdPerM(p.cached_input_per_mtok)}</Td>
211 + <Td num label="Context" className="tnum text-ink-2">{num(p.context_length) === null ? <span className="text-ink-3">—</span> : fmtTokens(p.context_length)}</Td>
212 + <Td label="Observed" className="text-ink-2" title={p.observed_at}>{fmtAgo(p.observed_at)}</Td>
213 + <Td label="Source"><SourceCell url={p.source_url} tier={p.tier} /></Td>
214 + </tr>
215 + );
216 + })}
217 + </tbody>
218 + </DataTable>
219 + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
220 + <Note className="mt-3">
221 + “Cheapest” marks the lowest input price for a model among the rows shown on this page (models served by several providers). USD per 1M tokens as published; <Link href={routes.providers()} className="link">providers overview</Link> · <Link href="/methodology" className="link">methodology</Link>.
222 + </Note>
223 + </>
224 + )}
225 + </Section>
226 + </Container>
227 + );
228 +}
modified apps/web/src/app/providers/page.tsx +10 −2
@@ -1,5 +1,7 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
3 5 import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4 6 import { EntityLink, QualityMark } from '@/components/ui/entity';
5 7 import { Container, Note, PageHeader } from '@/components/ui/section';
@@ -42,8 +44,13 @@ export default async function ProvidersPage() {
42 44 {items.map((p) => (
43 45 <tr key={p.id}>
44 46 <Td primary>
45 <EntityLink e={p} />
46 {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>}
47 + <div className="flex items-start justify-between gap-2">
48 + <div className="min-w-0">
49 + <EntityLink e={p} />
50 + {p.organization && <span className="ml-2 text-xs text-ink-3">{p.organization.name}</span>}
51 + </div>
52 + <CompareButton e={p} size="sm" className="mt-0.5" />
53 + </div>
47 54 </Td>
48 55 <Td num label="Models" className="tnum">{fmtInt(p.model_count)}</Td>
49 56 <Td num label="Price rows" className="tnum text-ink-2">{fmtInt(p.price_count)}</Td>
@@ -59,6 +66,7 @@ export default async function ProvidersPage() {
59 66 </>
60 67 )}
61 68 </div>
69 + <CompareTrayBar />
62 70 </Container>
63 71 );
64 72 }
modified apps/web/src/app/robots.ts +1 −1
@@ -3,7 +3,7 @@ import { SITE_URL } from '@/lib/site';
3 3
4 4 export default function robots(): MetadataRoute.Robots {
5 5 return {
6 rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/search?'] }],
6 + rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin', '/admin/', '/search?'] }],
7 7 sitemap: `${SITE_URL}/sitemap.xml`,
8 8 host: SITE_URL,
9 9 };
added apps/web/src/app/timeline/loading.tsx +25 −0
@@ -0,0 +1,25 @@
1 +import { Container } from '@/components/ui/section';
2 +
3 +export default function Loading() {
4 + return (
5 + <Container className="pb-16 pt-7 md:pt-10" aria-busy="true">
6 + <div className="h-4 w-20 animate-pulse bg-surface-2" />
7 + <div className="mt-3 h-9 w-72 max-w-full animate-pulse bg-surface-2" />
8 + <div className="mt-6 flex gap-1.5">
9 + {Array.from({ length: 5 }).map((_, i) => (
10 + <div key={i} className="h-9 w-16 animate-pulse bg-surface-2" />
11 + ))}
12 + </div>
13 + <div className="mt-8 h-[90px] animate-pulse bg-surface-2" />
14 + <ul className="mt-8 border-t border-rule">
15 + {Array.from({ length: 8 }).map((_, i) => (
16 + <li key={i} className="border-b border-rule py-3">
17 + <div className="h-4 w-1/2 animate-pulse bg-surface-2" />
18 + <div className="mt-2 h-3 w-3/4 animate-pulse bg-surface-2" />
19 + </li>
20 + ))}
21 + </ul>
22 + <p className="sr-only">Loading timeline…</p>
23 + </Container>
24 + );
25 +}
modified apps/web/src/app/timeline/page.tsx +136 −49
@@ -1,73 +1,160 @@
1 1 import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { ChangeRow } from '@/components/changes/change-row';
4 import { FilterBar } from '@/components/listing/filters';
5 import { withParams } from '@/components/ui/pagination';
6 import { Container, PageHeader } from '@/components/ui/section';
4 +import { Bars } from '@/components/charts/charts';
5 +import { ChipRow, type ChipItem } from '@/components/timeline/chip-row';
6 +import { EntityBadge } from '@/components/ui/badges';
7 +import { Container, Note, PageHeader } from '@/components/ui/section';
7 8 import { EmptyState, Unavailable } from '@/components/ui/unavailable';
8 9 import { api, safe } from '@/lib/api';
9 import { fmtInt, fmtMonth } from '@/lib/format';
10 import { CATEGORY_LABELS, routes } from '@/lib/site';
10 +import { fmtInt, fmtMonth, fmtYear } from '@/lib/format';
11 +import { CATEGORY_LABELS, categoryLabel, routes, SITE_NAME } from '@/lib/site';
11 12
12 export const metadata: Metadata = { title: 'Timeline — the AI ecosystem month by month', description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity.', alternates: { canonical: '/timeline' } };
13 13 export const revalidate = 600;
14 14
15 15 type SP = Record<string, string | undefined>;
16 const YEARS = Array.from({ length: 8 }, (_, i) => String(new Date().getUTCFullYear() - i));
16 +const LIMIT = 400;
17 +
18 +function current(sp: SP) {
19 + const out: { entity?: string; year?: string; category?: string } = {};
20 + if (sp.entity) out.entity = sp.entity;
21 + if (sp.year && /^\d{4}$/.test(sp.year)) out.year = sp.year;
22 + if (sp.category) out.category = sp.category;
23 + return out;
24 +}
25 +
26 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
27 + const cur = current(await searchParams);
28 + const bits: string[] = [];
29 + if (cur.entity) bits.push(cur.entity);
30 + if (cur.category) bits.push(categoryLabel(cur.category).toLowerCase());
31 + if (cur.year) bits.push(cur.year);
32 + const title = bits.length ? `Timeline — ${bits.join(' · ')}` : 'Timeline — the AI ecosystem month by month';
33 + return {
34 + title,
35 + description: 'Change events grouped by month: releases, price moves, deprecations and benchmark results across the whole atlas or for one entity, each linked to its source.',
36 + alternates: { canonical: routes.timeline(cur) },
37 + openGraph: { title: `${title} | ${SITE_NAME}` },
38 + robots: cur.entity ? { index: false, follow: true } : undefined,
39 + };
40 +}
17 41
18 42 export default async function TimelinePage({ searchParams }: { searchParams: Promise<SP> }) {
19 43 const sp = await searchParams;
20 const current: Record<string, string | undefined> = {};
21 for (const k of ['entity', 'year', 'category']) if (sp[k]) current[k] = sp[k];
22 const res = await safe(api.timeline({ ...current, limit: 400 }));
44 + const cur = current(sp);
45 + const [res, cats, stats, entity] = await Promise.all([safe(api.timeline({ ...cur, limit: LIMIT })), safe(api.changesCategories(365)), safe(api.stats()), cur.entity ? safe(api.entity(cur.entity)) : Promise.resolve(null)]);
23 46 const months = res?.items ?? [];
24 const total = months.reduce((n, m) => n + m.events.length, 0);
25 const entity = current.entity ? await safe(api.entity(current.entity)) : null;
47 + const shown = months.reduce((n, m) => n + m.events.length, 0);
48 + const total = res?.total ?? shown;
49 +
50 + // Years: current UTC year back to the earliest year the atlas knows (first entity or earliest month returned).
51 + const thisYear = new Date().getUTCFullYear();
52 + const earliestCandidates = [stats?.first_entity_at, ...months.map((m) => m.month), cur.year].filter((v): v is string => !!v).map((v) => Number(fmtYear(v))).filter((n) => Number.isFinite(n) && n > 1990);
53 + const earliest = earliestCandidates.length ? Math.min(...earliestCandidates) : thisYear;
54 + const years: string[] = [];
55 + for (let y = thisYear; y >= earliest; y--) years.push(String(y));
56 +
57 + // Categories: live counts over the last year, summed per category; fall back to the vocabulary when unavailable.
58 + const catCounts = new Map<string, number>();
59 + for (const it of cats?.items ?? []) catCounts.set(it.category, (catCounts.get(it.category) ?? 0) + (Number(it.count) || 0));
60 + const catKeys = catCounts.size ? [...catCounts.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k) : Object.keys(CATEGORY_LABELS);
61 + if (cur.category && !catKeys.includes(cur.category)) catKeys.push(cur.category);
62 +
63 + const yearChips: ChipItem[] = [{ href: routes.timeline({ ...cur, year: undefined }), label: 'All years', active: !cur.year }, ...years.map((y) => ({ href: routes.timeline({ ...cur, year: y }), label: y, active: cur.year === y }))];
64 + const catChips: ChipItem[] = [{ href: routes.timeline({ ...cur, category: undefined }), label: 'All categories', active: !cur.category }, ...catKeys.map((c) => ({ href: routes.timeline({ ...cur, category: c }), label: categoryLabel(c), count: catCounts.size ? catCounts.get(c) ?? 0 : undefined, active: cur.category === c }))];
65 +
66 + // Density strip, oldest → newest (the list below is newest first).
67 + const density = [...months].reverse().map((m) => ({ label: fmtMonth(m.month), value: m.count ?? m.events.length }));
68 +
26 69 return (
27 70 <Container>
28 <PageHeader eyebrow="Timeline" title={entity ? <>Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link></> : 'The ecosystem, month by month'} lede="Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change." aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(total)} events shown</p> : undefined}>
29 <FilterBar
30 action="/timeline"
31 className="mt-6 lg:grid-cols-4"
32 resetHref={routes.timeline()}
33 fields={[
34 { kind: 'text', name: 'entity', label: 'Entity slug', value: current.entity, placeholder: 'e.g. claude-opus-5' },
35 { kind: 'select', name: 'year', label: 'Year', value: current.year, options: YEARS.map((y) => ({ value: y, label: y })) },
36 { kind: 'select', name: 'category', label: 'Category', value: current.category, options: Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })) },
37 ]}
38 />
71 + <PageHeader
72 + eyebrow={
73 + entity ? (
74 + <>
75 + <EntityBadge type={entity.entity_type} small /> Timeline
76 + </>
77 + ) : (
78 + 'Timeline'
79 + )
80 + }
81 + title={
82 + entity ? (
83 + <>
84 + Timeline of <Link href={routes.entity(entity)} className="text-ink-2 hover:text-accent">{entity.name}</Link>
85 + </>
86 + ) : cur.entity ? (
87 + <>Timeline of <span className="mono text-ink-2">{cur.entity}</span></>
88 + ) : (
89 + 'The ecosystem, month by month'
90 + )
91 + }
92 + lede={entity ? <>Every recorded change for this {entity.entity_type.replace(/_/g, ' ')}{entity.organization ? ` by ${entity.organization.name}` : ''}, newest first. <Link href={routes.entity(entity)} className="link">Back to the entity page →</Link></> : 'Events grouped by month, newest first. Every entry links to the entity and to the source that stated the change.'}
93 + aside={res ? <p className="tnum text-sm text-ink-3">{fmtInt(shown)}{total > shown ? ` of ${fmtInt(total)}` : ''} events</p> : undefined}
94 + >
95 + <div className="mt-6 space-y-3">
96 + <div>
97 + <p className="eyebrow mb-1.5">Year</p>
98 + <ChipRow label="Year" items={yearChips} />
99 + </div>
100 + <div>
101 + <p className="eyebrow mb-1.5">Category {cats ? <span className="normal-case tracking-normal text-ink-3">· events in the last 365 days</span> : null}</p>
102 + <ChipRow label="Category" items={catChips} />
103 + </div>
104 + {cur.entity && (
105 + <p className="text-xs text-ink-3">
106 + Filtered to one entity. <Link href={routes.timeline({ year: cur.year, category: cur.category })} className="link">Show the whole atlas</Link>
107 + </p>
108 + )}
109 + </div>
39 110 </PageHeader>
111 +
40 112 <div className="pb-16">
41 113 {!res ? (
42 <Unavailable what="Timeline" />
114 + <Unavailable what="Timeline" reason={cur.entity ? 'The entity slug may not exist.' : undefined} />
43 115 ) : months.length === 0 ? (
44 <EmptyState title="No events for this selection">Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.</EmptyState>
116 + <EmptyState title="No events for this selection">
117 + Try another year or category, or open <Link href={routes.changes()} className="link">the live feed</Link>.
118 + </EmptyState>
45 119 ) : (
46 <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]">
47 <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0">
48 {months.map((m) => (
49 <a key={m.month} href={`#m-${m.month}`} className="tnum flex shrink-0 items-center justify-between gap-2 border border-rule px-2 py-1 text-xs text-ink-2 hover:text-ink lg:border-0 lg:px-1">
50 <span>{fmtMonth(m.month)}</span>
51 <span className="text-ink-3">{m.events.length}</span>
52 </a>
53 ))}
54 </nav>
55 <div className="min-w-0">
56 {months.map((m) => (
57 <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8">
58 <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0">
59 {fmtMonth(m.month)} <span className="tnum text-ink-3">{m.events.length}</span>
60 </h2>
61 <ul className="border-t border-rule">
62 {m.events.map((e) => (
63 <ChangeRow key={e.id} e={e} showDate live={false} />
64 ))}
65 </ul>
66 </section>
67 ))}
68 <p className="text-xs text-ink-3">Showing up to 400 events. Narrow by year, category or entity{current.entity ? '' : `, or use ${withParams('/changes', {}, {})} for the cursor-paged feed`}.</p>
120 + <>
121 + {density.length > 1 && (
122 + <section aria-label="Events per month" className="mb-8">
123 + <p className="eyebrow mb-2">Events per month <span className="tnum normal-case tracking-normal text-ink-3">· {fmtInt(density.length)} months shown</span></p>
124 + <Bars data={density} height={90} />
125 + </section>
126 + )}
127 + <div className="grid gap-x-10 lg:grid-cols-[10rem_minmax(0,1fr)]">
128 + <nav aria-label="Months" className="no-scrollbar -mx-4 mb-4 flex gap-1 overflow-x-auto px-4 lg:sticky lg:top-[calc(var(--header-h)+1rem)] lg:mx-0 lg:mb-0 lg:block lg:self-start lg:space-y-0.5 lg:px-0">
129 + {months.map((m) => (
130 + <a key={m.month} href={`#m-${m.month}`} className="tnum flex h-9 shrink-0 items-center justify-between gap-2 border border-rule px-2 text-xs text-ink-2 hover:text-ink lg:h-8 lg:border-0 lg:px-1">
131 + <span>{fmtMonth(m.month)}</span>
132 + <span className="text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>
133 + </a>
134 + ))}
135 + </nav>
136 + <div className="min-w-0">
137 + {months.map((m) => (
138 + <section key={m.month} id={`m-${m.month}`} className="scroll-mt-20 pb-8">
139 + <h2 className="eyebrow sticky top-[var(--header-h)] z-10 -mx-4 flex items-baseline justify-between bg-canvas/95 px-4 py-2 backdrop-blur md:mx-0 md:px-0">
140 + <span>
141 + {fmtMonth(m.month)} <span className="tnum text-ink-3">{fmtInt(m.count ?? m.events.length)}</span>
142 + </span>
143 + {(m.count ?? m.events.length) > m.events.length && <span className="tnum normal-case tracking-normal text-ink-3">{fmtInt(m.events.length)} shown</span>}
144 + </h2>
145 + <ul className="border-t border-rule">
146 + {m.events.map((e) => (
147 + <ChangeRow key={e.id} e={e} showDate live={false} />
148 + ))}
149 + </ul>
150 + </section>
151 + ))}
152 + <Note>
153 + Showing up to {fmtInt(LIMIT)} events{total > shown ? ` of ${fmtInt(total)} matching` : ''}. Narrow by year or category, or use <Link href={routes.changes()} className="link">the changes feed</Link> for cursor-paged history. Times are UTC.
154 + </Note>
155 + </div>
69 156 </div>
70 </div>
157 + </>
71 158 )}
72 159 </div>
73 160 </Container>
modified apps/web/src/app/tools/page.tsx +63 −3
@@ -1,10 +1,70 @@
1 1 import type { Metadata } from 'next';
2 import { GenericListing } from '@/components/listing/generic-listing';
2 +import Link from 'next/link';
3 +import { Dash, str, TypedListing } from '@/components/listing/typed-listing';
4 +import { EntityBadge } from '@/components/ui/badges';
5 +import { EntityLink, QualityMark } from '@/components/ui/entity';
6 +import { api } from '@/lib/api';
7 +import { fmtAgo, fmtInt, num } from '@/lib/format';
8 +import { routes } from '@/lib/site';
3 9
4 export const metadata: Metadata = { title: 'AI tools, agents and products', description: 'Developer tools, agents, MCP servers and products in the AI ecosystem, with sources and history.', alternates: { canonical: '/tools' } };
10 +export const metadata: Metadata = { title: 'AI tools, agents and MCP servers', description: 'Developer tools, agents, products and MCP servers in the atlas with language, license, stars and publisher from their official sources.', alternates: { canonical: '/tools' } };
5 11 export const revalidate = 300;
6 12
7 13 export default async function ToolsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
8 14 const sp = await searchParams;
9 return <GenericListing type="tool" basePath="/tools" eyebrow="Tools" title="Tools, agents & products" lede="Developer tools, agents and MCP servers — as described by their own documentation." searchParams={sp} />;
15 + return (
16 + <TypedListing
17 + title="Tools"
18 + eyebrow="Tools"
19 + lede="Agents, coding tools, MCP servers and products built on the models above. Metadata comes from repositories, package registries and official pages."
20 + basePath="/tools"
21 + searchParams={sp}
22 + fetch={(q) => api.explore('tool', q)}
23 + sorts={[
24 + { value: 'updated', label: 'Recently updated' },
25 + { value: 'stars', label: 'Stars' },
26 + { value: 'name', label: 'Name' },
27 + { value: 'quality', label: 'Data quality' },
28 + ]}
29 + filters={sp.org ? [{ kind: 'hidden', name: 'org', value: sp.org }] : []}
30 + emptyTitle={sp.q ? 'No tools match' : 'No tools recorded yet'}
31 + emptyHint={sp.q ? 'Try another name.' : <>Connectors are populating this type. Related software lives under <Link href={routes.frameworks()} className="link">Frameworks</Link> and <Link href={routes.exploreType('repository')} className="link">Repositories</Link> in the meantime.</>}
32 + columns={[
33 + {
34 + key: 'name',
35 + label: 'Tool',
36 + primary: true,
37 + render: (e) => (
38 + <>
39 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
40 + <EntityLink e={e} />
41 + <EntityBadge type={e.entity_type} small />
42 + </span>
43 + {e.description && <span className="block max-w-md truncate text-xs text-ink-3">{e.description}</span>}
44 + </>
45 + ),
46 + },
47 + { key: 'kind', label: 'Category', className: 'text-ink-2', render: (e) => str(e.attributes?.category) ?? str(e.attributes?.kind) ?? <Dash /> },
48 + { key: 'language', label: 'Language', className: 'text-ink-2', render: (e) => str(e.attributes?.language) ?? <Dash /> },
49 + { key: 'stars', label: 'Stars', num: true, className: 'tnum', render: (e) => (num(e.attributes?.['metric.stars']) === null ? <Dash /> : fmtInt(e.attributes['metric.stars'])) },
50 + { key: 'license', label: 'License', className: 'max-w-[10rem] truncate text-ink-2', render: (e) => str(e.attributes?.license) ?? <Dash /> },
51 + {
52 + key: 'org',
53 + label: 'Organization',
54 + className: 'text-ink-2',
55 + render: (e) =>
56 + e.organization ? (
57 + <Link href={routes.entity({ entity_type: 'company', slug: e.organization.slug })} className="hover:text-accent">
58 + {e.organization.name}
59 + </Link>
60 + ) : (
61 + <Dash />
62 + ),
63 + },
64 + { key: 'updated', label: 'Updated', className: 'text-ink-2 whitespace-nowrap', render: (e) => <span title={e.updated_at}>{fmtAgo(e.updated_at)}</span> },
65 + { key: 'quality', label: 'Quality', num: true, render: (e) => <QualityMark q={e.quality?.score} /> },
66 + ]}
67 + note="Tools cover several entity types (tool, agent, MCP server, product) — the badge on each row says which."
68 + />
69 + );
10 70 }
added apps/web/src/components/admin/login-form.tsx +26 −0
@@ -0,0 +1,26 @@
1 +'use client';
2 +import { useActionState } from 'react';
3 +import { loginAction } from '@/lib/admin/actions';
4 +
5 +/** Login form: posts the token to a server action; the token only ever travels in this request body and the httpOnly cookie. */
6 +export function LoginForm({ hint }: { hint?: string | null }) {
7 + const [state, action, pending] = useActionState(loginAction, { error: null });
8 + return (
9 + <form action={action} className="mt-6 max-w-sm space-y-3">
10 + <label className="block">
11 + <span className="eyebrow block pb-1">Admin token</span>
12 + <input type="password" name="token" required autoComplete="off" autoFocus spellCheck={false} className="h-11 w-full border border-rule-strong bg-surface px-3 text-[16px] text-ink focus:border-accent focus:outline-none" />
13 + </label>
14 + <button type="submit" disabled={pending} className="inline-flex h-11 w-full items-center justify-center bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-60">
15 + {pending ? 'Checking…' : 'Sign in'}
16 + </button>
17 + {state?.error && (
18 + <p role="alert" className="text-sm text-danger">
19 + {state.error}
20 + </p>
21 + )}
22 + {!state?.error && hint && <p className="text-xs text-ink-3">{hint}</p>}
23 + <p className="text-xs text-ink-3">The token is validated against the API and kept in an httpOnly cookie for 12 hours. It is never sent to the browser.</p>
24 + </form>
25 + );
26 +}
added apps/web/src/components/admin/nav-select.tsx +21 −0
@@ -0,0 +1,21 @@
1 +'use client';
2 +import { usePathname, useRouter } from 'next/navigation';
3 +
4 +/** Mobile section switcher for the admin shell (< lg): a native select that navigates. */
5 +export function AdminNavSelect({ items }: { items: { href: string; label: string }[] }) {
6 + const router = useRouter();
7 + const pathname = usePathname();
8 + const current = items.find((i) => pathname === i.href || pathname.startsWith(i.href + '/'))?.href ?? items[0]?.href ?? '';
9 + return (
10 + <label className="block lg:hidden">
11 + <span className="sr-only">Admin section</span>
12 + <select value={current} onChange={(e) => router.push(e.target.value)} className="h-11 w-full border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none">
13 + {items.map((i) => (
14 + <option key={i.href} value={i.href}>
15 + {i.label}
16 + </option>
17 + ))}
18 + </select>
19 + </label>
20 + );
21 +}
added apps/web/src/components/admin/shell.tsx +59 −0
@@ -0,0 +1,59 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { logoutAction } from '@/lib/admin/actions';
4 +import { AdminNavSelect } from './nav-select';
5 +
6 +export const ADMIN_NAV = [
7 + { href: '/admin/overview', label: 'Overview' },
8 + { href: '/admin/connectors', label: 'Connectors' },
9 + { href: '/admin/runs', label: 'Runs' },
10 + { href: '/admin/errors', label: 'Errors' },
11 + { href: '/admin/documents', label: 'Documents' },
12 + { href: '/admin/jobs', label: 'Jobs' },
13 + { href: '/admin/llm-jobs', label: 'LLM jobs' },
14 + { href: '/admin/review', label: 'Review queue' },
15 + { href: '/admin/entities/duplicates', label: 'Duplicates' },
16 + { href: '/admin/infrastructure', label: 'Infrastructure' },
17 + { href: '/admin/cache', label: 'Cache' },
18 +];
19 +
20 +/** Admin shell: header line + left nav (≥ lg) / select (mobile). Server component; the nav highlight is done by the client select and CSS-less links. */
21 +export function AdminShell({ children, where }: { children: ReactNode; where?: string | null }) {
22 + return (
23 + <div className="py-5 md:py-6">
24 + <div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-rule pb-3">
25 + <p className="mono text-xs text-ink-3">
26 + <span className="text-ink">AI Atlas admin</span>
27 + {where && <span> · {where}</span>}
28 + </p>
29 + <div className="flex items-center gap-3 text-xs">
30 + <Link href="/" className="text-ink-3 hover:text-ink">
31 + ← public site
32 + </Link>
33 + <form action={logoutAction}>
34 + <button type="submit" className="h-8 border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">
35 + Sign out
36 + </button>
37 + </form>
38 + </div>
39 + </div>
40 + <div className="grid gap-6 lg:grid-cols-[11rem_minmax(0,1fr)]">
41 + <aside className="min-w-0">
42 + <AdminNavSelect items={ADMIN_NAV} />
43 + <nav aria-label="Admin sections" className="hidden lg:block lg:sticky lg:top-[calc(var(--header-h)+1rem)]">
44 + <ul className="space-y-px">
45 + {ADMIN_NAV.map((n) => (
46 + <li key={n.href}>
47 + <Link href={n.href} className="flex min-h-8 items-center px-1 text-sm text-ink-2 hover:bg-surface-2 hover:text-ink">
48 + {n.label}
49 + </Link>
50 + </li>
51 + ))}
52 + </ul>
53 + </nav>
54 + </aside>
55 + <div className="min-w-0">{children}</div>
56 + </div>
57 + </div>
58 + );
59 +}
added apps/web/src/components/admin/ui.tsx +161 −0
@@ -0,0 +1,161 @@
1 +import Link from 'next/link';
2 +import type { ReactNode } from 'react';
3 +import { cn } from '@/lib/cn';
4 +import { DASH } from '@/lib/format';
5 +
6 +/* Dense admin primitives on the shared tokens. No marketing, no cards. */
7 +
8 +const chip = 'inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide';
9 +
10 +const HEALTH: Record<string, string> = {
11 + ok: 'text-positive bg-positive-soft',
12 + success: 'text-positive bg-positive-soft',
13 + done: 'text-positive bg-positive-soft',
14 + approved: 'text-positive bg-positive-soft',
15 + extracted: 'text-positive bg-positive-soft',
16 + active: 'text-positive bg-positive-soft',
17 + degraded: 'text-warning bg-warning-soft',
18 + partial: 'text-warning bg-warning-soft',
19 + skipped: 'text-warning bg-warning-soft',
20 + queued: 'text-accent bg-accent-soft',
21 + running: 'text-accent bg-accent-soft',
22 + pending: 'text-accent bg-accent-soft',
23 + failing: 'text-danger bg-danger-soft',
24 + failed: 'text-danger bg-danger-soft',
25 + error: 'text-danger bg-danger-soft',
26 + dead: 'text-danger bg-danger-soft',
27 + rejected: 'text-danger bg-danger-soft',
28 + schema_error: 'text-danger bg-danger-soft',
29 + blocked: 'text-danger bg-danger-soft',
30 + disabled: 'text-ink-3 bg-surface-2',
31 + unknown: 'text-ink-3 bg-surface-2',
32 +};
33 +
34 +/** Health / status chip: ok → positive · degraded → warning · failing → danger · disabled/unknown → muted. */
35 +export function StatusChip({ value, className }: { value: string | null | undefined; className?: string }) {
36 + if (!value) return <span className="text-ink-3">{DASH}</span>;
37 + return <span className={cn(chip, 'mono', HEALTH[value] ?? 'text-ink-2 bg-surface-2', className)}>{value}</span>;
38 +}
39 +
40 +export function KindChip({ value, className }: { value: string; className?: string }) {
41 + return <span className={cn(chip, 'mono bg-surface-2 text-ink-2', className)}>{value}</span>;
42 +}
43 +
44 +export function Bool({ v }: { v: boolean | null | undefined }) {
45 + if (v === null || v === undefined) return <span className="text-ink-3">{DASH}</span>;
46 + return <span className={v ? 'text-positive' : 'text-ink-3'}>{v ? 'yes' : 'no'}</span>;
47 +}
48 +
49 +/** Inline notice from `?notice=&level=` after a server action. */
50 +export function Notice({ notice, level }: { notice?: string; level?: string }) {
51 + if (!notice) return null;
52 + const err = level === 'error';
53 + return (
54 + <p role="status" className={cn('mb-4 border-l-2 px-3 py-2 text-sm', err ? 'border-danger bg-danger-soft text-danger' : 'border-positive bg-positive-soft text-ink')}>
55 + {notice}
56 + </p>
57 + );
58 +}
59 +
60 +/** Pretty JSON in a scrollable block; never dumps undefined/null as text. */
61 +export function JsonPre({ value, className, maxHeight = '24rem' }: { value: unknown; className?: string; maxHeight?: string }) {
62 + if (value === null || value === undefined) return <p className="text-xs text-ink-3">none</p>;
63 + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
64 + return (
65 + <pre className={cn('scrollbar-thin overflow-auto border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink', className)} style={{ maxHeight }}>
66 + <code>{text}</code>
67 + </pre>
68 + );
69 +}
70 +
71 +/** Small action button used inside server-action forms. */
72 +export function ActionButton({ children, tone = 'neutral', className, disabled, title }: { children: ReactNode; tone?: 'neutral' | 'accent' | 'danger' | 'positive'; className?: string; disabled?: boolean; title?: string }) {
73 + return (
74 + <button
75 + type="submit"
76 + disabled={disabled}
77 + title={title}
78 + className={cn(
79 + 'inline-flex h-8 items-center justify-center whitespace-nowrap border px-2.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40',
80 + tone === 'neutral' && 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink',
81 + tone === 'accent' && 'border-accent text-accent hover:bg-accent-soft',
82 + tone === 'positive' && 'border-positive text-positive hover:bg-positive-soft',
83 + tone === 'danger' && 'border-danger text-danger hover:bg-danger-soft',
84 + className,
85 + )}
86 + >
87 + {children}
88 + </button>
89 + );
90 +}
91 +
92 +/** Title line for an admin section. */
93 +export function AdminTitle({ title, count, children, lede }: { title: string; count?: ReactNode; children?: ReactNode; lede?: ReactNode }) {
94 + return (
95 + <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
96 + <div className="min-w-0">
97 + <h1 className="text-xl font-semibold tracking-tight">
98 + {title} {count !== undefined && <span className="tnum text-base font-normal text-ink-3">{count}</span>}
99 + </h1>
100 + {lede && <p className="mt-1 text-xs text-ink-3">{lede}</p>}
101 + </div>
102 + {children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
103 + </div>
104 + );
105 +}
106 +
107 +/** Compact GET filter form (selects/text) for admin listings. */
108 +export function AdminFilters({ action, fields, className }: { action: string; fields: ({ kind: 'select'; name: string; label: string; value?: string; options: { value: string; label: string }[]; any?: string } | { kind: 'text'; name: string; label: string; value?: string; placeholder?: string } | { kind: 'hidden'; name: string; value: string })[]; className?: string }) {
109 + const cls = 'h-9 w-full border border-rule bg-surface px-2 text-xs text-ink focus:border-accent focus:outline-none';
110 + return (
111 + <form action={action} method="get" className={cn('grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6', className)}>
112 + {fields.map((f) =>
113 + f.kind === 'hidden' ? (
114 + <input key={f.name} type="hidden" name={f.name} value={f.value} />
115 + ) : (
116 + <label key={f.name} className="block min-w-0">
117 + <span className="eyebrow block pb-1">{f.label}</span>
118 + {f.kind === 'select' ? (
119 + <select name={f.name} defaultValue={f.value ?? ''} className={cls}>
120 + <option value="">{f.any ?? 'Any'}</option>
121 + {f.options.map((o) => (
122 + <option key={o.value} value={o.value}>
123 + {o.label}
124 + </option>
125 + ))}
126 + </select>
127 + ) : (
128 + <input name={f.name} defaultValue={f.value ?? ''} placeholder={f.placeholder} className={cls} />
129 + )}
130 + </label>
131 + ),
132 + )}
133 + <div className="flex items-end gap-2">
134 + <button type="submit" className="h-9 flex-1 bg-ink px-3 text-xs font-medium text-canvas hover:opacity-90">
135 + Apply
136 + </button>
137 + <Link href={action} className="inline-flex h-9 items-center border border-rule px-3 text-xs text-ink-2 hover:text-ink">
138 + Reset
139 + </Link>
140 + </div>
141 + </form>
142 + );
143 +}
144 +
145 +export function Mono({ children, className, title }: { children: ReactNode; className?: string; title?: string }) {
146 + return (
147 + <span className={cn('mono text-[11.5px] text-ink-2', className)} title={title}>
148 + {children}
149 + </span>
150 + );
151 +}
152 +
153 +/** Truncate long text with the full text as title. */
154 +export function Trunc({ text, max = 80, className }: { text: string | null | undefined; max?: number; className?: string }) {
155 + if (!text) return <span className="text-ink-3">{DASH}</span>;
156 + return (
157 + <span className={className} title={text.length > max ? text : undefined}>
158 + {text.length > max ? `${text.slice(0, max)}…` : text}
159 + </span>
160 + );
161 +}
added apps/web/src/components/benchmarks/leaderboard.tsx +191 −0
@@ -0,0 +1,191 @@
1 +import Link from 'next/link';
2 +import { Legend, LineChart } from '@/components/charts/charts';
3 +import { CompareButton } from '@/components/compare/compare-button';
4 +import { ConfidenceBadge } from '@/components/ui/badges';
5 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6 +import { EntityLink } from '@/components/ui/entity';
7 +import { Pagination } from '@/components/ui/pagination';
8 +import { SourceCell } from '@/components/ui/provenance';
9 +import { Note } from '@/components/ui/section';
10 +import { EmptyState } from '@/components/ui/unavailable';
11 +import { cn } from '@/lib/cn';
12 +import { fmtDate, fmtInt, fmtScore } from '@/lib/format';
13 +import { routes } from '@/lib/site';
14 +import type { BenchmarkResult } from '@/lib/types';
15 +
16 +/** Per-model identifiers (one row each) are useless as filters; everything else becomes a chip candidate. */
17 +const PER_ROW_KEYS = new Set(['aa_slug', 'model_tag', 'model_id', 'run_id', 'submission_id', 'date', 'submitted_at', 'url']);
18 +
19 +/** Distinct config values seen on the page (value → count), skipping per-row identifiers and singletons. */
20 +export function configChips(results: BenchmarkResult[], max = 10): { value: string; key: string; count: number }[] {
21 + const counts = new Map<string, { key: string; count: number }>();
22 + for (const r of results) {
23 + for (const [k, v] of Object.entries(r.config ?? {})) {
24 + if (PER_ROW_KEYS.has(k) || v === null || v === undefined || v === '') continue;
25 + const s = typeof v === 'object' ? JSON.stringify(v) : String(v);
26 + if (s.length > 40) continue;
27 + const cur = counts.get(s);
28 + if (cur) cur.count += 1;
29 + else counts.set(s, { key: k, count: 1 });
30 + }
31 + }
32 + return [...counts.entries()]
33 + .map(([value, x]) => ({ value, key: x.key, count: x.count }))
34 + .filter((c) => c.count > 1 && c.count < results.length)
35 + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value))
36 + .slice(0, max);
37 +}
38 +
39 +export function configSummary(c: Record<string, unknown>): string {
40 + return Object.entries(c ?? {})
41 + .filter(([, v]) => v !== null && v !== undefined && v !== '')
42 + .slice(0, 4)
43 + .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
44 + .join(' · ');
45 +}
46 +
47 +export function ConfigChips({ slug, chips, active, model }: { slug: string; chips: ReturnType<typeof configChips>; active?: string; model?: string }) {
48 + if (!chips.length && !active) return null;
49 + const cls = (on: boolean) => cn('inline-flex h-8 items-center gap-1.5 border px-2.5 text-xs whitespace-nowrap', on ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink');
50 + return (
51 + <nav aria-label="Configuration filter" className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
52 + <Link href={routes.benchmark(slug, { model })} className={cls(!active)} aria-current={!active ? 'true' : undefined}>
53 + All configs
54 + </Link>
55 + {active && !chips.some((c) => c.value === active) && (
56 + <Link href={routes.benchmark(slug, { model })} className={cls(true)} aria-current="true" title="Remove this filter">
57 + <span className="mono">{active}</span> ×
58 + </Link>
59 + )}
60 + {chips.map((c) => (
61 + <Link key={c.value} href={routes.benchmark(slug, { config: active === c.value ? undefined : c.value, model })} className={cls(active === c.value)} aria-current={active === c.value ? 'true' : undefined} title={`config.${c.key} contains “${c.value}” (${c.count} rows on this page)`}>
62 + <span className="text-ink-3 opacity-80">{c.key}</span> <span className="mono">{c.value}</span>
63 + </Link>
64 + ))}
65 + </nav>
66 + );
67 +}
68 +
69 +/**
70 + * Leaderboard table: rank · model (+org) · score with a relative bar · config · evaluated · source · compare/history.
71 + * Bars are relative to the best score of the page (per direction) — a reading aid, not a normalisation.
72 + */
73 +export function Leaderboard({ slug, results, total, limit, offset, config, model, unit, makeHref }: { slug: string; results: BenchmarkResult[]; total: number; limit: number; offset: number; config?: string; model?: string; unit?: string | null; makeHref: (offset: number) => string }) {
74 + if (!results.length) return <EmptyState title={config ? 'No result matches this configuration filter' : 'No benchmark results recorded'}>{config ? <Link href={routes.benchmark(slug, { model })} className="link">Clear the filter</Link> : 'Results appear when a tier 1–3 source publishes them; we never copy scores without a source.'}</EmptyState>;
75 + const hib = results[0]?.higher_is_better !== false;
76 + const scores = results.map((r) => r.score).filter((s) => Number.isFinite(s));
77 + const max = Math.max(...scores);
78 + const min = Math.min(...scores);
79 + const width = (s: number) => {
80 + if (!Number.isFinite(s) || max <= 0) return 0;
81 + const v = hib ? s / max : min > 0 ? min / s : 0;
82 + return Math.max(2, Math.min(100, v * 100));
83 + };
84 + const u = unit ?? results[0]?.unit ?? null;
85 + return (
86 + <>
87 + <DataTable caption="Leaderboard">
88 + <thead>
89 + <tr>
90 + <Th className="w-10">#</Th>
91 + <Th>Model</Th>
92 + <Th num>Score</Th>
93 + <Th>Config</Th>
94 + <Th>Evaluated</Th>
95 + <Th>Source</Th>
96 + <Th className="w-40"><span className="sr-only">Actions</span></Th>
97 + </tr>
98 + </thead>
99 + <tbody>
100 + {results.length === 0 && <EmptyRow cols={7} />}
101 + {results.map((r, i) => {
102 + const rank = offset + i + 1;
103 + const isModel = model && r.model.slug === model;
104 + return (
105 + <tr key={r.id} className={isModel ? 'bg-accent-soft/40' : undefined}>
106 + <Td className="tnum text-ink-3" hideStack>{fmtInt(rank)}</Td>
107 + <Td primary>
108 + <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
109 + <span className="tnum text-xs text-ink-3 md:hidden">#{fmtInt(rank)}</span>
110 + <EntityLink e={r.model} />
111 + </span>
112 + {r.model.organization && <span className="block text-xs text-ink-3">{r.model.organization.name}</span>}
113 + </Td>
114 + <Td num label="Score" className="tnum font-medium">
115 + <span className="inline-flex flex-col items-end gap-1">
116 + <span>
117 + {fmtScore(r.score)}
118 + {u === '%' ? '%' : u ? <span className="text-ink-3"> {u}</span> : null}
119 + </span>
120 + <span className="block h-1 w-24 overflow-hidden rounded-sm bg-surface-2" aria-hidden>
121 + <span className="block h-full" style={{ width: `${width(r.score)}%`, background: 'var(--type-benchmark)' }} />
122 + </span>
123 + </span>
124 + </Td>
125 + <Td label="Config" className="mono max-w-[18rem] truncate text-xs text-ink-3" title={JSON.stringify(r.config)}>{configSummary(r.config) || '—'}</Td>
126 + <Td label="Evaluated" className="tnum text-ink-2 whitespace-nowrap" title={r.evaluated_at ? undefined : `Observed ${fmtDate(r.observed_at)}; the source gave no evaluation date`}>
127 + {r.evaluated_at ? fmtDate(r.evaluated_at) : <span className="text-ink-3">— <span className="text-[11px]">(obs. {fmtDate(r.observed_at)})</span></span>}
128 + </Td>
129 + <Td label="Source">
130 + <SourceCell url={r.source_url} tier={r.tier} /> <ConfidenceBadge confidence={r.confidence !== 'high' && r.confidence !== 'medium' ? r.confidence : null} />
131 + </Td>
132 + <Td className="text-right">
133 + <span className="inline-flex flex-wrap items-center justify-end gap-1.5">
134 + <Link href={routes.benchmark(slug, { config, model: isModel ? undefined : r.model.slug })} className={cn('inline-flex h-7 items-center border px-1.5 text-xs whitespace-nowrap', isModel ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')} aria-pressed={!!isModel}>
135 + History
136 + </Link>
137 + <CompareButton e={r.model} size="sm" />
138 + </span>
139 + </Td>
140 + </tr>
141 + );
142 + })}
143 + </tbody>
144 + </DataTable>
145 + <Pagination total={total} limit={limit} offset={offset} makeHref={makeHref} className="mt-4" />
146 + <Note className="mt-3">
147 + Scores are reported as published, with their evaluation configuration (harness, prompting, judge). The bar is relative to the best score on this page{hib ? '' : ' (lower is better)'}. Results with different configs are not directly comparable — see <Link href="/methodology#benchmarks" className="link">methodology</Link>.
148 + </Note>
149 + </>
150 + );
151 +}
152 +
153 +/** Score history for one model on this benchmark (all rows incl. superseded), as a time series. */
154 +export function HistoryChart({ items, model, unit }: { items: BenchmarkResult[]; model: string; unit?: string | null }) {
155 + const name = items[0]?.model.name ?? model;
156 + const pts = items
157 + .map((r) => ({ x: new Date(r.evaluated_at ?? r.observed_at), y: r.score, r }))
158 + .filter((p) => !Number.isNaN(p.x.getTime()) && Number.isFinite(p.y))
159 + .sort((a, b) => a.x.getTime() - b.x.getTime());
160 + const u = unit ?? items[0]?.unit ?? null;
161 + const fmt = (v: number) => `${fmtScore(v)}${u === '%' ? '%' : ''}`;
162 + const days = new Set(pts.map((p) => p.x.toISOString().slice(0, 10)));
163 + return (
164 + <div>
165 + <p className="eyebrow mb-2">
166 + Score history · {name} <span className="tnum text-ink-3">{fmtInt(items.length)} {items.length === 1 ? 'row' : 'rows'}</span>
167 + </p>
168 + {pts.length < 2 || days.size < 2 ? (
169 + <Note>
170 + Not enough history to chart — {pts.length === 0 ? 'no result recorded for this model' : pts.length === 1 ? `a single observation (${fmt(pts[0]?.y ?? 0)} on ${fmtDate(pts[0]?.r.evaluated_at ?? pts[0]?.r.observed_at)})` : `${fmtInt(pts.length)} observations, all dated ${fmtDate(pts[0]?.r.evaluated_at ?? pts[0]?.r.observed_at)}`}. Rows under different configurations count separately; the list below shows each one.
171 + </Note>
172 + ) : (
173 + <>
174 + <LineChart series={[{ name, color: 'var(--type-benchmark)', points: pts.map((p) => ({ x: p.x, y: p.y })) }]} height={200} yFormat={fmt} showDots yLabel={`Score history for ${name}`} />
175 + <Legend series={[{ name, color: 'var(--type-benchmark)' }]} className="mt-2" />
176 + </>
177 + )}
178 + {pts.length > 0 && (
179 + <ul className="mt-3 divide-y divide-rule border-y border-rule text-sm">
180 + {[...pts].reverse().slice(0, 12).map((p) => (
181 + <li key={p.r.id} className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5">
182 + <span className="tnum font-medium">{fmt(p.y)}</span>
183 + <span className="mono truncate text-xs text-ink-3" title={JSON.stringify(p.r.config)}>{configSummary(p.r.config) || '—'}</span>
184 + <span className="tnum text-xs text-ink-2">{fmtDate(p.r.evaluated_at ?? p.r.observed_at)}</span>
185 + </li>
186 + ))}
187 + </ul>
188 + )}
189 + </div>
190 + );
191 +}
added apps/web/src/components/brand/og.tsx +79 −0
@@ -0,0 +1,79 @@
1 +/** Shared chrome for per-entity Open Graph images (ImageResponse cannot read CSS variables → token hex values inlined). */
2 +export const INK = '#e9ebf0';
3 +export const INK2 = '#a3a9b8';
4 +export const INK3 = '#6f7688';
5 +export const ACCENT = '#6d95ff';
6 +
7 +function Mark({ px = 56 }: { px?: number }) {
8 + return (
9 + <svg width={px} height={px} viewBox="0 0 32 32" fill="none">
10 + <circle cx="16" cy="16" r="13" stroke={INK} strokeWidth="1.6" />
11 + <path d="M16 3c-4.4 3.2-6.6 7.6-6.6 13s2.2 9.8 6.6 13M16 3c4.4 3.2 6.6 7.6 6.6 13s-2.2 9.8-6.6 13M3 16h26" stroke={INK} strokeWidth="1.2" opacity="0.7" />
12 + <path d="M9.4 9.5L16 16l6.6-6.5M16 16l-5.2 8.4M16 16l7.4 5.6" stroke={INK} strokeWidth="1.2" opacity="0.55" />
13 + <circle cx="16" cy="16" r="2.4" fill={ACCENT} />
14 + <circle cx="9.4" cy="9.5" r="1.7" fill={INK} />
15 + <circle cx="22.6" cy="9.5" r="1.7" fill={INK} />
16 + <circle cx="10.8" cy="24.4" r="1.7" fill={INK} />
17 + <circle cx="23.4" cy="21.6" r="1.7" fill={INK} />
18 + </svg>
19 + );
20 +}
21 +
22 +export function Frame({ children, footer }: { children: React.ReactNode; footer: string }) {
23 + return (
24 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px', background: 'linear-gradient(160deg, #12151b 0%, #0b0d11 55%, #0b0d11 100%)', color: INK, fontFamily: 'sans-serif', position: 'relative' }}>
25 + <div style={{ position: 'absolute', right: -140, top: -160, width: 640, height: 640, borderRadius: 999, background: 'radial-gradient(circle at 40% 40%, rgba(109,149,255,0.18), rgba(11,13,17,0) 70%)', display: 'flex' }} />
26 + <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
27 + <Mark />
28 + <div style={{ display: 'flex', fontSize: 32, fontWeight: 600, letterSpacing: -1 }}>
29 + <span>AI</span>
30 + <span style={{ color: INK2, marginLeft: 9, fontWeight: 500 }}>Atlas</span>
31 + </div>
32 + </div>
33 + {children}
34 + <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', color: INK3, fontSize: 21 }}>
35 + <span>{footer}</span>
36 + <span>source-attributed · with history</span>
37 + </div>
38 + </div>
39 + );
40 +}
41 +
42 +export function Eyebrow({ children, color = ACCENT }: { children: string; color?: string }) {
43 + return <div style={{ display: 'flex', fontSize: 18, letterSpacing: 3, textTransform: 'uppercase', color, fontWeight: 600 }}>{children}</div>;
44 +}
45 +
46 +export function Title({ children }: { children: string }) {
47 + const long = children.length > 26;
48 + return (
49 + <div style={{ display: 'flex', fontSize: long ? 58 : 74, fontWeight: 600, lineHeight: 1.04, letterSpacing: -2.2, maxWidth: 1040, overflow: 'hidden', maxHeight: long ? 128 : 160 }}>
50 + <span style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>{children}</span>
51 + </div>
52 + );
53 +}
54 +
55 +export function Facts({ items }: { items: [string, string][] }) {
56 + if (!items.length) return null;
57 + return (
58 + <div style={{ display: 'flex', gap: 52, marginTop: 10 }}>
59 + {items.map(([label, value]) => (
60 + <div key={label} style={{ display: 'flex', flexDirection: 'column' }}>
61 + <span style={{ fontSize: 15, letterSpacing: 2.5, textTransform: 'uppercase', color: INK3, fontWeight: 600 }}>{label}</span>
62 + <span style={{ fontSize: 34, fontWeight: 600, color: INK, letterSpacing: -1, marginTop: 6 }}>{value}</span>
63 + </div>
64 + ))}
65 + </div>
66 + );
67 +}
68 +
69 +export function Fallback({ label }: { label: string }) {
70 + return (
71 + <Frame footer="www.ai-atlas.co">
72 + <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
73 + <Eyebrow>{label}</Eyebrow>
74 + <Title>Explore the entire AI ecosystem.</Title>
75 + <div style={{ fontSize: 24, color: INK2, display: 'flex' }}>Models · companies · research · providers & pricing · benchmarks · hardware</div>
76 + </div>
77 + </Frame>
78 + );
79 +}
modified apps/web/src/components/charts/charts.tsx +26 −8
@@ -1,5 +1,5 @@
1 1 import { extent, max } from 'd3-array';
2 import { scaleLinear, scaleTime } from 'd3-scale';
2 +import { scaleLinear, scaleLog, scaleTime } from 'd3-scale';
3 3 import { area, curveMonotoneX, line } from 'd3-shape';
4 4 import { cn } from '@/lib/cn';
5 5 import { fmtCompact } from '@/lib/format';
@@ -97,9 +97,15 @@ export function HBars({ data, className, color = 'var(--series-1)', format = fmt
97 97
98 98 export type Series = { name: string; color?: string; points: Point[] };
99 99
100 /** Multi-series line chart with time or linear x axis, light grid, end labels. */
101 export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number] }) {
102 const all = series.flatMap((s) => s.points).filter((p) => Number.isFinite(p.y));
100 +/**
101 + * Multi-series line chart with time or linear x axis, light grid, end labels.
102 + * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices
103 + * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0).
104 + */
105 +export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear' }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log' }) {
106 + const log = yScale === 'log';
107 + const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0);
108 + const all = series.flatMap((s) => s.points).filter(keep);
103 109 if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>;
104 110 const w = 720;
105 111 const pad = { l: 44, r: 16, t: 12, b: 24 };
@@ -112,11 +118,15 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
112 118 y0 = y0 * 0.9;
113 119 y1 = y1 * 1.1 || 1;
114 120 }
115 if (!yDomain) y0 = Math.min(0, y0);
116 const y = scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]);
121 + if (!yDomain && !log) y0 = Math.min(0, y0);
122 + if (log) {
123 + y0 = Math.max(Number.EPSILON, y0 <= 0 ? Math.min(...ys.filter((v) => v > 0)) : y0) / 1.25;
124 + y1 = y1 * 1.25;
125 + }
126 + const y = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]);
117 127 const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number;
118 128 const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX);
119 const ticks = y.ticks(4);
129 + const ticks = log ? logTicks(y0, y1) : y.ticks(4);
120 130 const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5);
121 131 const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v));
122 132 return (
@@ -135,7 +145,7 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
135 145 </text>
136 146 ))}
137 147 {series.map((s, i) => {
138 const pts = s.points.filter((p) => Number.isFinite(p.y)).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x)));
148 + const pts = s.points.filter(keep).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x)));
139 149 if (pts.length === 0) return null;
140 150 const color = s.color ?? `var(--series-${(i % 8) + 1})`;
141 151 const last = pts[pts.length - 1] as Point;
@@ -151,6 +161,14 @@ export function LineChart({ series, height = 220, className, yFormat = fmtCompac
151 161 );
152 162 }
153 163
164 +/** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */
165 +function logTicks(lo: number, hi: number): number[] {
166 + const out: number[] = [];
167 + for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e);
168 + if (out.length < 2) return [lo, hi];
169 + return out;
170 +}
171 +
154 172 export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) {
155 173 return (
156 174 <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>
added apps/web/src/components/compare/compare-button.tsx +39 −0
@@ -0,0 +1,39 @@
1 +'use client';
2 +import { Check, Plus } from 'lucide-react';
3 +import { cn } from '@/lib/cn';
4 +import { COMPARE_MAX, type TrayItem, trayType, useCompareTray } from './compare-store';
5 +
6 +/**
7 + * Small toggle that adds/removes an entity from the compare tray (localStorage-backed, mirrored into `/compare?ids=`).
8 + * `size="sm"` for table rows (still a ≥ 44 px hit area through padding on touch), default for entity headers.
9 + * Renders a neutral placeholder before hydration so server and client markup match.
10 + */
11 +export function CompareButton({ e, size = 'md', className, label = 'Compare' }: { e: TrayItem | { slug: string; name: string; entity_type: string; organization?: { name: string } | null }; size?: 'sm' | 'md'; className?: string; label?: string }) {
12 + const tray = useCompareTray();
13 + const item: TrayItem = { slug: e.slug, name: e.name, entity_type: e.entity_type, organization: typeof e.organization === 'string' || e.organization === null || e.organization === undefined ? (e.organization as string | null | undefined) : e.organization.name };
14 + const on = tray.ready && tray.has(item.slug);
15 + const otherType = tray.ready && tray.type !== null && tray.type !== trayType(item.entity_type) && !on;
16 + const full = tray.ready && tray.full && !on;
17 + const title = on ? 'Remove from comparison' : otherType ? `Start a new ${item.entity_type} comparison (replaces the current tray)` : full ? `The tray holds at most ${COMPARE_MAX} items` : 'Add to comparison';
18 + return (
19 + <button
20 + type="button"
21 + onClick={() => {
22 + if (full) return;
23 + tray.toggle(item);
24 + }}
25 + aria-pressed={on}
26 + title={title}
27 + disabled={full}
28 + className={cn(
29 + 'inline-flex shrink-0 items-center gap-1 border text-xs font-medium whitespace-nowrap transition-colors disabled:cursor-not-allowed disabled:opacity-40',
30 + size === 'sm' ? 'h-7 px-1.5' : 'h-9 px-2.5 text-sm',
31 + on ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink',
32 + className,
33 + )}
34 + >
35 + {on ? <Check className={size === 'sm' ? 'size-3' : 'size-3.5'} aria-hidden /> : <Plus className={size === 'sm' ? 'size-3' : 'size-3.5'} aria-hidden />}
36 + <span>{on ? 'Comparing' : label}</span>
37 + </button>
38 + );
39 +}
added apps/web/src/components/compare/compare-matrix.tsx +312 −0
@@ -0,0 +1,312 @@
1 +import { Chip, TierBadge } from '@/components/ui/badges';
2 +import { DataTable, Td, Th } from '@/components/ui/data-table';
3 +import { EntityLink } from '@/components/ui/entity';
4 +import { Note } from '@/components/ui/section';
5 +import { cn } from '@/lib/cn';
6 +import { fmtAgo, fmtDate, fmtScore, fmtUsdPerM, fmtValue, num } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { BenchmarkResult, CompareDimension, ComparePayload, Price, ProvenanceEntry } from '@/lib/types';
9 +import { CompareButton } from './compare-button';
10 +
11 +/* ------------------------------------------------------------------------------------------------------ helpers */
12 +
13 +const PRICE_KEYS = new Set(['best_input_per_mtok', 'best_output_per_mtok', 'min_input_per_mtok', 'min_output_per_mtok']);
14 +/** Numeric dimensions where lower is better. */
15 +function lowerIsBetter(key: string): boolean {
16 + return PRICE_KEYS.has(key) || /tdp|price|latency/.test(key);
17 +}
18 +
19 +function host(url: string | null | undefined): string | null {
20 + if (!url) return null;
21 + try {
22 + return new URL(url).hostname.replace(/^www\./, '');
23 + } catch {
24 + return null;
25 + }
26 +}
27 +
28 +/** Compact per-cell provenance: T1 · host · 3 h ago (full text in title). */
29 +function CellProvenance({ p }: { p: ProvenanceEntry }) {
30 + const h = p.source_name ?? host(p.url) ?? 'source';
31 + return (
32 + <span className="mt-0.5 hidden items-center gap-1 text-[11px] leading-4 text-ink-3 md:flex">
33 + <TierBadge tier={p.tier} />
34 + {p.url ? (
35 + <a href={p.url} target="_blank" rel="noopener noreferrer" className="max-w-[10rem] truncate hover:text-accent">
36 + {h}
37 + </a>
38 + ) : (
39 + <span className="max-w-[10rem] truncate">{h}</span>
40 + )}
41 + <span>· {fmtAgo(p.observed_at)}</span>
42 + </span>
43 + );
44 +}
45 +
46 +function CellValue({ v, dim }: { v: unknown; dim: CompareDimension }) {
47 + if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) return <span className="text-ink-3">Unavailable</span>;
48 + switch (dim.kind) {
49 + case 'date':
50 + return <>{fmtDate(String(v))}</>;
51 + case 'bool':
52 + return <>{v ? 'Yes' : 'No'}</>;
53 + case 'list': {
54 + const arr = Array.isArray(v) ? v : [v];
55 + return (
56 + <span className="flex flex-wrap gap-1">
57 + {arr.map((x, i) => (
58 + <Chip key={i}>{typeof x === 'object' && x ? JSON.stringify(x) : String(x)}</Chip>
59 + ))}
60 + </span>
61 + );
62 + }
63 + case 'number':
64 + if (PRICE_KEYS.has(dim.key)) return <span className="text-accent-2">{fmtUsdPerM(v)}</span>;
65 + return <>{fmtValue(v, dim.key)}</>;
66 + default:
67 + return <>{typeof v === 'string' ? v : fmtValue(v, dim.key)}</>;
68 + }
69 +}
70 +
71 +const STICKY = 'sticky left-0 z-10 bg-canvas';
72 +/** First-column width: table layout ignores max-width, so set width + min-width explicitly. */
73 +const FIRST = 'w-[9rem] min-w-[9rem] md:w-[12rem] md:min-w-[12rem]';
74 +/** `.table-scroll td { white-space: nowrap }` out-specifies utilities — wrap the first column inline. */
75 +const WRAP = { whiteSpace: 'normal' } as const;
76 +
77 +/** Horizontal scroll wrapper without the negative page margins (so the sticky column pins at the very left edge). */
78 +function ScrollTable({ children, caption, className }: { children: React.ReactNode; caption: string; className?: string }) {
79 + return (
80 + <div className="table-scroll">
81 + <DataTable stack={false} caption={caption} className={className}>
82 + {children}
83 + </DataTable>
84 + </div>
85 + );
86 +}
87 +
88 +function EntityHead({ e }: { e: ComparePayload['items'][number]['entity'] }) {
89 + return (
90 + <span className="flex flex-col items-start gap-1 normal-case tracking-normal">
91 + <EntityLink e={e} className="text-sm font-semibold whitespace-normal" />
92 + {e.organization && <span className="text-[11px] font-normal text-ink-3">{e.organization.name}</span>}
93 + <CompareButton e={e} size="sm" label="Add" />
94 + </span>
95 + );
96 +}
97 +
98 +/* ------------------------------------------------------------------------------------------------------ matrix */
99 +
100 +/** Dimension × entity matrix: sticky first column, horizontal scroll, kind-aware formatting, per-cell provenance. */
101 +export function CompareMatrix({ res }: { res: ComparePayload }) {
102 + return (
103 + <>
104 + <ScrollTable caption="Comparison matrix" className="compare-matrix">
105 + <thead>
106 + <tr>
107 + <Th className={cn(STICKY, FIRST)} style={WRAP}>Dimension</Th>
108 + {res.items.map((it) => (
109 + <Th key={it.entity.id} className="min-w-[10rem] align-top">
110 + <EntityHead e={it.entity} />
111 + </Th>
112 + ))}
113 + </tr>
114 + </thead>
115 + <tbody>
116 + {res.dimensions.map((dim) => {
117 + const nums = res.items.map((it) => num(it.values[dim.key]));
118 + const present = nums.filter((n): n is number => n !== null);
119 + const best = dim.kind === 'number' && present.length > 1 ? (lowerIsBetter(dim.key) ? Math.min(...present) : Math.max(...present)) : null;
120 + return (
121 + <tr key={dim.key}>
122 + <Td className={cn(STICKY, FIRST, 'text-ink-2')} style={WRAP}>
123 + {dim.label}
124 + {dim.unit && <span className="block text-[11px] text-ink-3">{dim.unit}</span>}
125 + </Td>
126 + {res.items.map((it, i) => {
127 + const v = it.values[dim.key];
128 + const p = it.provenance?.[dim.key];
129 + const isBest = best !== null && nums[i] === best;
130 + return (
131 + <Td key={it.entity.id} className={cn('align-top', dim.kind === 'number' && 'tnum', dim.kind === 'list' && 'whitespace-normal')} title={p ? `${p.source_name ?? host(p.url) ?? ''} · tier ${p.tier} · ${fmtDate(p.observed_at)}` : undefined}>
132 + <span className={cn('block', isBest && 'font-semibold text-ink')}>
133 + <CellValue v={v} dim={dim} />
134 + </span>
135 + {p && <CellProvenance p={p} />}
136 + </Td>
137 + );
138 + })}
139 + </tr>
140 + );
141 + })}
142 + </tbody>
143 + </ScrollTable>
144 + <Note className="mt-3">Bold marks the best number in a row (highest, or lowest for prices); it is not a verdict. Each value carries its own source and tier; hover a cell for the observation date. Missing values are shown as unavailable, never estimated.</Note>
145 + </>
146 + );
147 +}
148 +
149 +/* ------------------------------------------------------------------------------------------------------ shared benchmarks */
150 +
151 +function configSummary(c: Record<string, unknown>): string {
152 + return Object.entries(c ?? {})
153 + .filter(([, v]) => v !== null && v !== undefined && v !== '')
154 + .slice(0, 4)
155 + .map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
156 + .join(' · ');
157 +}
158 +
159 +/** Benchmarks with a current result for every compared model (best result per model per benchmark). */
160 +export function SharedBenchmarks({ res }: { res: ComparePayload }) {
161 + const perItem = res.items.map((it) => {
162 + const m = new Map<string, BenchmarkResult>();
163 + for (const r of it.results ?? []) {
164 + const prev = m.get(r.benchmark.slug);
165 + if (!prev || (r.higher_is_better === false ? r.score < prev.score : r.score > prev.score)) m.set(r.benchmark.slug, r);
166 + }
167 + return m;
168 + });
169 + const first = perItem[0];
170 + const shared = first ? [...first.values()].filter((r) => perItem.every((m) => m.has(r.benchmark.slug))) : [];
171 + const anyResults = res.items.some((it) => (it.results?.length ?? 0) > 0);
172 + if (shared.length === 0)
173 + return (
174 + <p className="text-sm text-ink-3">
175 + {anyResults ? 'No benchmark has a published result for every compared model.' : 'No benchmark results recorded for these models.'} Per-model results are on each model page (Benchmarks tab).
176 + </p>
177 + );
178 + shared.sort((a, b) => a.benchmark.name.localeCompare(b.benchmark.name));
179 + return (
180 + <>
181 + <ScrollTable caption="Shared benchmark results">
182 + <thead>
183 + <tr>
184 + <Th className={cn(STICKY, FIRST)} style={WRAP}>Benchmark</Th>
185 + {res.items.map((it) => (
186 + <Th key={it.entity.id} className="min-w-[8rem]">
187 + <EntityLink e={it.entity} className="text-sm font-semibold normal-case tracking-normal whitespace-normal" />
188 + </Th>
189 + ))}
190 + </tr>
191 + </thead>
192 + <tbody>
193 + {shared.map((row) => {
194 + const cells = perItem.map((m) => m.get(row.benchmark.slug) ?? null);
195 + const scores = cells.map((c) => (c ? c.score : null)).filter((s): s is number => s !== null);
196 + const best = scores.length > 1 ? (row.higher_is_better === false ? Math.min(...scores) : Math.max(...scores)) : null;
197 + return (
198 + <tr key={row.benchmark.slug}>
199 + <Td className={cn(STICKY, FIRST, 'text-ink-2')} style={WRAP}>
200 + <EntityLink e={row.benchmark} />
201 + <span className="block text-[11px] text-ink-3">
202 + {row.metric ?? '—'}
203 + {row.higher_is_better === false ? ' · lower is better' : ''}
204 + </span>
205 + </Td>
206 + {cells.map((c, i) => (
207 + <Td key={res.items[i]!.entity.id} className="tnum align-top" title={c ? configSummary(c.config) || undefined : undefined}>
208 + {c ? (
209 + <>
210 + <span className={cn('block', best !== null && c.score === best && 'font-semibold text-ink')}>
211 + {fmtScore(c.score)}
212 + {c.unit === '%' ? '%' : c.unit ? <span className="text-ink-3"> {c.unit}</span> : ''}
213 + </span>
214 + <span className="mt-0.5 hidden items-center gap-1 text-[11px] text-ink-3 md:flex">
215 + <TierBadge tier={c.tier} />
216 + <span className="max-w-[10rem] truncate">{configSummary(c.config) || (c.evaluated_at ? fmtDate(c.evaluated_at) : '')}</span>
217 + </span>
218 + </>
219 + ) : (
220 + <span className="text-ink-3">—</span>
221 + )}
222 + </Td>
223 + ))}
224 + </tr>
225 + );
226 + })}
227 + </tbody>
228 + </ScrollTable>
229 + <Note className="mt-3">Only benchmarks with a result for every compared model are shown (best current result per model). Configurations may differ — hover a score for its config; scores under different configs are not strictly comparable.</Note>
230 + </>
231 + );
232 +}
233 +
234 +/* ------------------------------------------------------------------------------------------------------ prices */
235 +
236 +type Best = { input: number | null; output: number | null; observed: string; url: string | null; tier: number };
237 +
238 +/** Provider × entity: cheapest current input / output per provider for each compared model. */
239 +export function ComparePrices({ res }: { res: ComparePayload }) {
240 + const providers = new Map<string, Price['provider']>();
241 + const perItem = res.items.map((it) => {
242 + const m = new Map<string, Best>();
243 + for (const p of it.prices ?? []) {
244 + providers.set(p.provider.slug, p.provider);
245 + const cur = m.get(p.provider.slug) ?? { input: null, output: null, observed: p.observed_at, url: p.source_url, tier: p.tier };
246 + const inp = num(p.input_per_mtok);
247 + const out = num(p.output_per_mtok);
248 + if (inp !== null && (cur.input === null || inp < cur.input)) cur.input = inp;
249 + if (out !== null && (cur.output === null || out < cur.output)) cur.output = out;
250 + if (p.observed_at > cur.observed) cur.observed = p.observed_at;
251 + m.set(p.provider.slug, cur);
252 + }
253 + return m;
254 + });
255 + if (providers.size === 0) return <p className="text-sm text-ink-3">No current prices recorded for these models.</p>;
256 + const rows = [...providers.values()].sort((a, b) => a.name.localeCompare(b.name));
257 + const bestOverall = res.items.map((_, i) => {
258 + let best: number | null = null;
259 + for (const b of perItem[i]!.values()) if (b.input !== null && (best === null || b.input < best)) best = b.input;
260 + return best;
261 + });
262 + return (
263 + <>
264 + <ScrollTable caption="Best current prices per provider (USD per 1M tokens)">
265 + <thead>
266 + <tr>
267 + <Th className={cn(STICKY, FIRST)} style={WRAP}>Provider</Th>
268 + {res.items.map((it) => (
269 + <Th key={it.entity.id} className="min-w-[9rem]">
270 + <EntityLink e={it.entity} className="text-sm font-semibold normal-case tracking-normal whitespace-normal" />
271 + <span className="block text-[10px] font-normal text-ink-3">in / out per 1M</span>
272 + </Th>
273 + ))}
274 + </tr>
275 + </thead>
276 + <tbody>
277 + {rows.map((prov) => (
278 + <tr key={prov.slug}>
279 + <Td className={cn(STICKY, FIRST)} style={WRAP}>
280 + <EntityLink e={prov} />
281 + </Td>
282 + {perItem.map((m, i) => {
283 + const b = m.get(prov.slug);
284 + if (!b) return <Td key={res.items[i]!.entity.id} className="text-ink-3">—</Td>;
285 + const cheapest = bestOverall[i] !== null && b.input === bestOverall[i];
286 + return (
287 + <Td key={res.items[i]!.entity.id} className="tnum align-top" title={`observed ${fmtDate(b.observed)}`}>
288 + <span className={cn('block text-accent-2', cheapest && 'font-semibold')}>
289 + {fmtUsdPerM(b.input)} <span className="text-ink-3">/</span> {fmtUsdPerM(b.output)}
290 + </span>
291 + <span className="mt-0.5 hidden items-center gap-1 text-[11px] text-ink-3 md:flex">
292 + <TierBadge tier={b.tier} />
293 + {b.url ? (
294 + <a href={b.url} target="_blank" rel="noopener noreferrer" className="max-w-[10rem] truncate hover:text-accent">
295 + {host(b.url)}
296 + </a>
297 + ) : null}
298 + <span>· {fmtAgo(b.observed)}</span>
299 + </span>
300 + </Td>
301 + );
302 + })}
303 + </tr>
304 + ))}
305 + </tbody>
306 + </ScrollTable>
307 + <Note className="mt-3">
308 + Cheapest current input / output price of each provider for each model, as published on the provider's pricing page (USD per 1M tokens). Bold = the model's cheapest provider. Full tables and history on each model page, or the <a href={routes.prices()} className="link">price index</a>.
309 + </Note>
310 + </>
311 + );
312 +}
added apps/web/src/components/compare/compare-picker.tsx +218 −0
@@ -0,0 +1,218 @@
1 +'use client';
2 +import { Search, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname, useRouter } from 'next/navigation';
5 +import { useEffect, useRef, useState } from 'react';
6 +import { EntityBadge } from '@/components/ui/badges';
7 +import { clientApi } from '@/lib/client-api';
8 +import { cn } from '@/lib/cn';
9 +import { typeLabel } from '@/lib/site';
10 +import type { Suggestion } from '@/lib/types';
11 +import { COMPARE_MAX, COMPARE_MIN, compareHref, type TrayItem, trayType, useCompareTray } from './compare-store';
12 +
13 +/**
14 + * Compare picker + tray for /compare. Suggestions come from /search/suggest; once the tray holds an item, only
15 + * entities of the same (normalised) type are selectable — the API compares 2–6 entities of one type.
16 + * `initial` (from `?ids=` resolved server-side) seeds the tray on load; tray changes are mirrored into `?ids=`.
17 + */
18 +export function ComparePicker({ initial, exampleHref }: { initial: TrayItem[]; exampleHref?: string | null }) {
19 + const tray = useCompareTray();
20 + const router = useRouter();
21 + const pathname = usePathname();
22 + const [q, setQ] = useState('');
23 + const [items, setItems] = useState<Suggestion[]>([]);
24 + const [loading, setLoading] = useState(false);
25 + const [failed, setFailed] = useState(false);
26 + const seeded = useRef(false);
27 + /** Slugs the tray must show before we start mirroring it into the URL (avoids stripping ?ids= during the seed commit). */
28 + const awaiting = useRef<string | null>(null);
29 + const inputRef = useRef<HTMLInputElement>(null);
30 +
31 + // Seed the tray from the URL once hydrated (URL wins over localStorage when present).
32 + useEffect(() => {
33 + if (!tray.ready || seeded.current) return;
34 + seeded.current = true;
35 + if (initial.length) {
36 + const same = initial.every((i) => trayType(i.entity_type) === trayType(initial[0]!.entity_type));
37 + const want = initial.map((i) => i.slug).join(',');
38 + const current = tray.items.map((i) => i.slug).join(',');
39 + if (same && current !== want) {
40 + awaiting.current = want;
41 + tray.replace(initial);
42 + }
43 + }
44 + }, [tray.ready, tray.items, tray.replace, initial]);
45 +
46 + // Mirror the tray into ?ids= (no scroll, replace) — only once the seeded items are in state.
47 + useEffect(() => {
48 + if (!tray.ready || !seeded.current) return;
49 + const ids = tray.items.map((i) => i.slug);
50 + if (awaiting.current !== null) {
51 + if (ids.join(',') !== awaiting.current) return;
52 + awaiting.current = null;
53 + }
54 + const url = new URL(window.location.href);
55 + const cur = url.searchParams.get('ids') ?? '';
56 + const next = ids.length >= COMPARE_MIN ? ids.join(',') : '';
57 + if (cur === next) return;
58 + if (next) url.searchParams.set('ids', next);
59 + else url.searchParams.delete('ids');
60 + router.replace(`${pathname}${url.search}`, { scroll: false });
61 + }, [tray.items, tray.ready, router, pathname]);
62 +
63 + useEffect(() => {
64 + const term = q.trim();
65 + if (term.length < 1) {
66 + setItems([]);
67 + return;
68 + }
69 + const ctrl = new AbortController();
70 + const t = setTimeout(async () => {
71 + setLoading(true);
72 + try {
73 + const res = await clientApi.suggest(term, ctrl.signal);
74 + setItems(res.items ?? []);
75 + setFailed(false);
76 + } catch (e) {
77 + if ((e as Error).name !== 'AbortError') setFailed(true);
78 + } finally {
79 + setLoading(false);
80 + }
81 + }, 120);
82 + return () => {
83 + clearTimeout(t);
84 + ctrl.abort();
85 + };
86 + }, [q]);
87 +
88 + const type = tray.type;
89 + const full = tray.full;
90 + const pick = (s: Suggestion) => {
91 + tray.add({ slug: s.slug, name: s.name, entity_type: s.entity_type, organization: s.organization_name });
92 + setQ('');
93 + setItems([]);
94 + inputRef.current?.focus();
95 + };
96 + const term = q.trim();
97 + const selectable = items.filter((s) => !tray.has(s.slug));
98 + const matching = type ? selectable.filter((s) => trayType(s.entity_type) === type) : selectable;
99 + const others = type ? selectable.filter((s) => trayType(s.entity_type) !== type) : [];
100 +
101 + return (
102 + <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
103 + {/* ---------------------------------------------------------------------------------------------- search */}
104 + <div className="min-w-0">
105 + <label htmlFor="compare-q" className="eyebrow block pb-1">
106 + {type ? `Add another ${typeLabel(type).toLowerCase()}` : 'Add an entity'}
107 + </label>
108 + <div className={cn('flex items-center gap-2 border border-rule-strong bg-surface px-3 focus-within:border-accent', full && 'opacity-60')}>
109 + <Search className="size-4 shrink-0 text-ink-3" aria-hidden />
110 + <input
111 + id="compare-q"
112 + ref={inputRef}
113 + value={q}
114 + onChange={(e) => setQ(e.target.value)}
115 + disabled={full}
116 + placeholder={full ? `Tray full (${COMPARE_MAX}) — remove one to add another` : type ? `Search ${typeLabel(type, true).toLowerCase()}…` : 'Search models, providers, hardware, companies…'}
117 + className="h-11 min-w-0 flex-1 bg-transparent text-[16px] text-ink placeholder:text-ink-3 focus:outline-none"
118 + autoComplete="off"
119 + spellCheck={false}
120 + aria-autocomplete="list"
121 + aria-controls="compare-suggestions"
122 + />
123 + {q && (
124 + <button type="button" onClick={() => setQ('')} className="flex size-9 items-center justify-center text-ink-3 hover:text-ink" aria-label="Clear">
125 + <X className="size-4" aria-hidden />
126 + </button>
127 + )}
128 + </div>
129 + {type && <p className="mt-1.5 text-xs text-ink-3">Comparing {typeLabel(type, true).toLowerCase()} — pick another {typeLabel(type).toLowerCase()}. Entities of another type would start a new comparison.</p>}
130 + {term && (
131 + <ul id="compare-suggestions" role="listbox" className="mt-1 border border-rule bg-surface">
132 + {failed && <li className="px-3 py-2 text-xs text-warning">Suggestions unavailable — try again in a moment.</li>}
133 + {!loading && !failed && matching.length === 0 && others.length === 0 && <li className="px-3 py-3 text-sm text-ink-3">{selectable.length === 0 && items.length > 0 ? 'Already in the tray.' : 'No match.'}</li>}
134 + {matching.map((s) => (
135 + <li key={s.id} role="option" aria-selected={false}>
136 + <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left hover:bg-surface-2">
137 + <EntityBadge type={s.entity_type} small />
138 + <span className="min-w-0 flex-1 truncate text-[15px] text-ink">{s.name}</span>
139 + {s.organization_name && <span className="hidden truncate text-xs text-ink-3 sm:block">{s.organization_name}</span>}
140 + </button>
141 + </li>
142 + ))}
143 + {others.length > 0 && (
144 + <li className="border-t border-rule px-3 py-1.5 text-[11px] text-ink-3">
145 + Other types (selecting one starts a new {' '}comparison)
146 + </li>
147 + )}
148 + {others.map((s) => (
149 + <li key={s.id} role="option" aria-selected={false}>
150 + <button type="button" onClick={() => pick(s)} className="flex min-h-[46px] w-full items-center gap-3 px-3 py-2 text-left text-ink-3 hover:bg-surface-2">
151 + <EntityBadge type={s.entity_type} small className="opacity-70" />
152 + <span className="min-w-0 flex-1 truncate text-[15px]">{s.name}</span>
153 + {s.organization_name && <span className="hidden truncate text-xs sm:block">{s.organization_name}</span>}
154 + </button>
155 + </li>
156 + ))}
157 + </ul>
158 + )}
159 + </div>
160 +
161 + {/* ---------------------------------------------------------------------------------------------- tray */}
162 + <div className="min-w-0">
163 + <p className="eyebrow pb-1">
164 + Tray <span className="tnum text-ink-3">{tray.ready ? tray.items.length : initial.length} / {COMPARE_MAX}</span>
165 + </p>
166 + <CompareTray items={tray.ready ? tray.items : initial} onRemove={tray.remove} onClear={tray.clear} ready={tray.ready} exampleHref={exampleHref} />
167 + </div>
168 + </div>
169 + );
170 +}
171 +
172 +export function CompareTray({ items, onRemove, onClear, ready, exampleHref }: { items: TrayItem[]; onRemove: (slug: string) => void; onClear: () => void; ready: boolean; exampleHref?: string | null }) {
173 + const can = items.length >= COMPARE_MIN;
174 + return (
175 + <div className="border-t border-rule">
176 + {items.length === 0 ? (
177 + <p className="py-3 text-sm text-ink-3">
178 + Nothing selected yet. Use the search on the left, or the “Compare” buttons on listings and entity pages.
179 + {exampleHref && (
180 + <>
181 + {' '}
182 + Example: <Link href={exampleHref} className="link">two well-documented models</Link>.
183 + </>
184 + )}
185 + </p>
186 + ) : (
187 + <ul className="divide-y divide-rule">
188 + {items.map((it) => (
189 + <li key={it.slug} className="flex min-h-11 items-center gap-2 py-1.5">
190 + <EntityBadge type={it.entity_type} small />
191 + <span className="min-w-0 flex-1 truncate text-sm text-ink">
192 + {it.name}
193 + {it.organization && <span className="ml-2 text-xs text-ink-3">{it.organization}</span>}
194 + </span>
195 + <button type="button" onClick={() => onRemove(it.slug)} disabled={!ready} className="flex size-9 shrink-0 items-center justify-center text-ink-3 hover:text-danger" aria-label={`Remove ${it.name}`}>
196 + <X className="size-4" aria-hidden />
197 + </button>
198 + </li>
199 + ))}
200 + </ul>
201 + )}
202 + <div className="mt-3 flex flex-wrap items-center gap-2">
203 + {can ? (
204 + <Link href={compareHref(items)} className="inline-flex h-10 items-center bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90">
205 + Compare {items.length} →
206 + </Link>
207 + ) : (
208 + <span className="inline-flex h-10 items-center border border-rule px-4 text-sm text-ink-3">Pick at least {COMPARE_MIN}</span>
209 + )}
210 + {items.length > 0 && (
211 + <button type="button" onClick={onClear} disabled={!ready} className="inline-flex h-10 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink">
212 + Clear
213 + </button>
214 + )}
215 + </div>
216 + </div>
217 + );
218 +}
added apps/web/src/components/compare/compare-store.ts +111 −0
@@ -0,0 +1,111 @@
1 +'use client';
2 +import { useCallback, useEffect, useState } from 'react';
3 +
4 +/**
5 + * Compare tray — the list of entity slugs the visitor is collecting for `/compare?ids=`.
6 + * Source of truth: `localStorage['aia-compare']` (mirrored across tabs through the `storage` event and a same-tab
7 + * custom event). The tray is type-homogeneous: the first item fixes the entity type; adding another type replaces
8 + * the tray (the API compares 2–6 entities of one type).
9 + */
10 +export const COMPARE_KEY = 'aia-compare';
11 +export const COMPARE_MAX = 6;
12 +export const COMPARE_MIN = 2;
13 +const EVENT = 'aia-compare-change';
14 +
15 +export type TrayItem = { slug: string; name: string; entity_type: string; organization?: string | null };
16 +
17 +function read(): TrayItem[] {
18 + if (typeof window === 'undefined') return [];
19 + try {
20 + const raw = window.localStorage.getItem(COMPARE_KEY);
21 + if (!raw) return [];
22 + const arr = JSON.parse(raw) as unknown;
23 + if (!Array.isArray(arr)) return [];
24 + return arr.filter((x): x is TrayItem => !!x && typeof x === 'object' && typeof (x as TrayItem).slug === 'string' && typeof (x as TrayItem).entity_type === 'string').slice(0, COMPARE_MAX);
25 + } catch {
26 + return [];
27 + }
28 +}
29 +
30 +function write(items: TrayItem[]) {
31 + if (typeof window === 'undefined') return;
32 + try {
33 + window.localStorage.setItem(COMPARE_KEY, JSON.stringify(items.slice(0, COMPARE_MAX)));
34 + } catch {
35 + /* storage full / disabled */
36 + }
37 + window.dispatchEvent(new CustomEvent(EVENT));
38 +}
39 +
40 +/** Normalises the API's type aliases so companies/labs/orgs compare together (the API accepts them under `company`). */
41 +export function trayType(entityType: string): string {
42 + if (['company', 'organization', 'lab', 'university'].includes(entityType)) return 'company';
43 + if (['framework', 'library', 'runtime'].includes(entityType)) return 'framework';
44 + if (entityType === 'quantization') return 'model';
45 + return entityType;
46 +}
47 +
48 +export function readTray(): TrayItem[] {
49 + return read();
50 +}
51 +export function setTray(items: TrayItem[]) {
52 + write(items);
53 +}
54 +export function clearTray() {
55 + write([]);
56 +}
57 +
58 +/** Add one item. Returns the new tray; a type change replaces the tray. */
59 +export function addToTray(item: TrayItem): TrayItem[] {
60 + const cur = read();
61 + const t = trayType(item.entity_type);
62 + const same = cur.filter((c) => trayType(c.entity_type) === t);
63 + if (same.some((c) => c.slug === item.slug)) return cur;
64 + const next = same.length === cur.length ? [...cur, item] : [item];
65 + const capped = next.slice(0, COMPARE_MAX);
66 + write(capped);
67 + return capped;
68 +}
69 +export function removeFromTray(slug: string): TrayItem[] {
70 + const next = read().filter((c) => c.slug !== slug);
71 + write(next);
72 + return next;
73 +}
74 +export function toggleTray(item: TrayItem): TrayItem[] {
75 + return read().some((c) => c.slug === item.slug) ? removeFromTray(item.slug) : addToTray(item);
76 +}
77 +
78 +/** React binding: returns the tray and mutators, subscribed to storage changes. `ready` is false during SSR/hydration. */
79 +export function useCompareTray() {
80 + const [items, setItems] = useState<TrayItem[]>([]);
81 + const [ready, setReady] = useState(false);
82 + useEffect(() => {
83 + const sync = () => setItems(read());
84 + sync();
85 + setReady(true);
86 + window.addEventListener(EVENT, sync);
87 + window.addEventListener('storage', sync);
88 + return () => {
89 + window.removeEventListener(EVENT, sync);
90 + window.removeEventListener('storage', sync);
91 + };
92 + }, []);
93 + const add = useCallback((item: TrayItem) => setItems(addToTray(item)), []);
94 + const remove = useCallback((slug: string) => setItems(removeFromTray(slug)), []);
95 + const toggle = useCallback((item: TrayItem) => setItems(toggleTray(item)), []);
96 + const clear = useCallback(() => {
97 + clearTray();
98 + setItems([]);
99 + }, []);
100 + const replace = useCallback((next: TrayItem[]) => {
101 + write(next);
102 + setItems(next.slice(0, COMPARE_MAX));
103 + }, []);
104 + const has = useCallback((slug: string) => items.some((c) => c.slug === slug), [items]);
105 + const type = items[0] ? trayType(items[0].entity_type) : null;
106 + return { items, ready, add, remove, toggle, clear, replace, has, type, full: items.length >= COMPARE_MAX, canCompare: items.length >= COMPARE_MIN };
107 +}
108 +
109 +export function compareHref(items: { slug: string }[]): string {
110 + return items.length ? `/compare?ids=${items.map((i) => encodeURIComponent(i.slug)).join(',')}` : '/compare';
111 +}
added apps/web/src/components/compare/compare-tray-bar.tsx +37 −0
@@ -0,0 +1,37 @@
1 +'use client';
2 +import { X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { typeLabel } from '@/lib/site';
6 +import { COMPARE_MAX, COMPARE_MIN, compareHref, useCompareTray } from './compare-store';
7 +
8 +/**
9 + * Floating tray summary shown on listings while the visitor collects entities: "n selected · Compare →".
10 + * Fixed above the mobile tab bar (< md) and bottom-right on desktop. Hidden on /compare itself and when empty.
11 + */
12 +export function CompareTrayBar() {
13 + const tray = useCompareTray();
14 + const pathname = usePathname();
15 + if (!tray.ready || tray.items.length === 0 || pathname === '/compare') return null;
16 + const can = tray.items.length >= COMPARE_MIN;
17 + return (
18 + <div role="status" aria-live="polite" className="fixed inset-x-2 bottom-[calc(var(--tabbar-h)+env(safe-area-inset-bottom,0px)+8px)] z-[44] md:inset-x-auto md:bottom-6 md:right-6">
19 + <div className="panel flex items-center gap-2 p-1.5 pl-3 shadow-lg">
20 + <p className="min-w-0 flex-1 text-sm text-ink-2">
21 + <span className="tnum font-medium text-ink">{tray.items.length}</span> / {COMPARE_MAX} {tray.type ? typeLabel(tray.type, true).toLowerCase() : 'selected'}
22 + <span className="hidden text-ink-3 sm:inline"> · {tray.items.map((i) => i.name).slice(0, 3).join(', ')}{tray.items.length > 3 ? ` +${tray.items.length - 3}` : ''}</span>
23 + </p>
24 + {can ? (
25 + <Link href={compareHref(tray.items)} className="inline-flex h-10 items-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90">
26 + Compare →
27 + </Link>
28 + ) : (
29 + <span className="inline-flex h-10 items-center px-2 text-xs text-ink-3">Pick {COMPARE_MIN - tray.items.length} more</span>
30 + )}
31 + <button type="button" onClick={tray.clear} className="flex size-10 items-center justify-center text-ink-3 hover:text-ink" aria-label="Clear comparison tray">
32 + <X className="size-4" aria-hidden />
33 + </button>
34 + </div>
35 + </div>
36 + );
37 +}
added apps/web/src/components/entity/asof-picker.tsx +53 −0
@@ -0,0 +1,53 @@
1 +'use client';
2 +import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3 +import { useState } from 'react';
4 +
5 +/**
6 + * "As of" date picker for the History tab. Writes `?tab=history&asof=YYYY-MM-DD` (keeping other params) so the
7 + * server renders the entity's attributes as known at that date (`GET /entities/{slug}/asof`).
8 + */
9 +export function AsOfPicker({ value }: { value?: string }) {
10 + const router = useRouter();
11 + const pathname = usePathname();
12 + const sp = useSearchParams();
13 + const [date, setDate] = useState(value ?? '');
14 + const today = new Date().toISOString().slice(0, 10);
15 +
16 + const go = (d: string | null) => {
17 + const next = new URLSearchParams(sp.toString());
18 + next.set('tab', 'history');
19 + if (d) next.set('asof', d);
20 + else next.delete('asof');
21 + router.replace(`${pathname}?${next.toString()}`, { scroll: false });
22 + };
23 +
24 + return (
25 + <form
26 + className="flex flex-wrap items-end gap-2"
27 + onSubmit={(e) => {
28 + e.preventDefault();
29 + if (date) go(date);
30 + }}
31 + >
32 + <label className="block min-w-0">
33 + <span className="eyebrow block pb-1">As of date (UTC)</span>
34 + <input type="date" name="asof" value={date} max={today} onChange={(e) => setDate(e.target.value)} className="h-11 w-full min-w-[11rem] border border-rule bg-surface px-2.5 text-sm text-ink focus:border-accent focus:outline-none" />
35 + </label>
36 + <button type="submit" disabled={!date} className="inline-flex h-11 items-center bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-40">
37 + View as of
38 + </button>
39 + {value && (
40 + <button
41 + type="button"
42 + onClick={() => {
43 + setDate('');
44 + go(null);
45 + }}
46 + className="inline-flex h-11 items-center border border-rule px-3 text-sm text-ink-2 hover:text-ink"
47 + >
48 + Back to today
49 + </button>
50 + )}
51 + </form>
52 + );
53 +}
modified apps/web/src/components/entity/entity-page.tsx +37 −8
@@ -1,14 +1,21 @@
1 import { ExternalLink } from 'lucide-react';
1 +import { ExternalLink, GitFork, MemoryStick } from 'lucide-react';
2 2 import Link from 'next/link';
3 +import { CompareButton } from '@/components/compare/compare-button';
3 4 import { ViewBeacon } from '@/components/layout/view-beacon';
4 5 import { EntityBadge, OpennessBadge, StatusBadge } from '@/components/ui/badges';
5 6 import { EntityLink, QualityMark } from '@/components/ui/entity';
6 7 import { Container, Note } from '@/components/ui/section';
7 8 import { TabPanel, Tabs, type TabDef } from '@/components/ui/tabs';
9 +import { api, safe } from '@/lib/api';
8 10 import { fmtAgo, fmtDate, fmtInt, fmtParams, fmtTokens, num } from '@/lib/format';
9 11 import { routes, SITE_NAME, SITE_URL, typeLabel } from '@/lib/site';
10 12 import type { EntityDetail, EntitySummary } from '@/lib/types';
11 13 import { Capabilities, EntityList, HardwareFitTable, Identity, LineageBlock, ModelsTable, PriceHistory, PricesTable, ProvenanceSummary, RelationsBlock, ResultsTable, SourcesTable, SpecTable, TimelineList } from './blocks';
14 +import { HistoryPanel } from './history';
15 +
16 +/** Search params an entity page understands (History tab). */
17 +export type EntityPageParams = { asof?: string; property?: string };
18 +const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
12 19
13 20 /* ---------------------------------------------------------------------------------------------------------- header chips */
14 21
@@ -93,15 +100,16 @@ function primaryUrl(d: EntityDetail): string | null {
93 100
94 101 /* ---------------------------------------------------------------------------------------------------------- tabs per type */
95 102
96 type TabKey = 'overview' | 'capabilities' | 'benchmarks' | 'providers' | 'hardware' | 'lineage' | 'research' | 'models' | 'repositories' | 'leaderboard' | 'runnable' | 'relations' | 'timeline' | 'sources';
103 +type TabKey = 'overview' | 'capabilities' | 'benchmarks' | 'providers' | 'hardware' | 'lineage' | 'research' | 'models' | 'repositories' | 'leaderboard' | 'runnable' | 'relations' | 'history' | 'timeline' | 'sources';
97 104
98 function tabsFor(d: EntityDetail): TabDef[] {
105 +function tabsFor(d: EntityDetail, claimCount?: number | null): TabDef[] {
99 106 const t = d.entity_type;
100 107 const c = (n: number | undefined | null) => (n ? n : undefined);
101 108 const rel = d.relations?.reduce((n, g) => n + g.items.length, 0) ?? 0;
102 109 const sources = d.sources?.length ?? 0;
103 110 const timeline = d.timeline?.length ?? 0;
104 111 const tail: TabDef[] = [
112 + { id: 'history', label: 'History', count: c(claimCount ?? d.counts?.claims) },
105 113 { id: 'timeline', label: 'Timeline', count: c(timeline) },
106 114 { id: 'sources', label: 'Sources', count: c(sources) },
107 115 ];
@@ -195,12 +203,18 @@ function jsonLd(d: EntityDetail, canonical: string) {
195 203 * Shared entity page for every type. Header (badges, name, org, description, key chips) + URL-driven tabs whose set
196 204 * depends on the type. Panels are all server-rendered (SEO); the client Tabs only toggles visibility.
197 205 */
198 export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonical: string; related?: EntitySummary[] | null }) {
206 +export async function EntityPage({ d, canonical, related, asof: asofRaw, historyProperty }: { d: EntityDetail; canonical: string; related?: EntitySummary[] | null; asof?: string; historyProperty?: string }) {
199 207 const a = d.attributes ?? {};
200 208 const chips = headerChips(d);
201 209 const openness = typeof a.openness === 'string' ? a.openness : null;
202 210 const link = primaryUrl(d);
203 const tabs = tabsFor(d);
211 + // History tab data: full claim history always (it is the tab's content); the "as of" state only when requested.
212 + const asof = asofRaw && ISO_DAY.test(asofRaw) ? asofRaw : asofRaw ? 'invalid' : undefined;
213 + const property = historyProperty?.trim() || undefined;
214 + const [history, asofPayload] = await Promise.all([safe(api.entityHistory(d.slug, property)), asof && asof !== 'invalid' ? safe(api.entityAsOf(d.slug, asof)) : Promise.resolve(null)]);
215 + const claims = history?.items ?? null;
216 + const tabs = tabsFor(d, property ? null : claims?.length);
217 + const memoryGb = d.entity_type === 'hardware' ? num(a.memory_gb) : null;
204 218 const isModel = d.entity_type === 'model' || d.entity_type === 'quantization';
205 219 const isCompany = ['company', 'organization', 'lab', 'university'].includes(d.entity_type);
206 220 const ld = jsonLd(d, canonical);
@@ -251,6 +265,17 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
251 265 )}
252 266 </p>
253 267 {d.description && <p className="mt-3 max-w-3xl text-[15px] leading-relaxed text-ink-2">{d.description}</p>}
268 + <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Actions">
269 + <CompareButton e={d} />
270 + <Link href={routes.graph(d.slug)} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
271 + <GitFork className="size-3.5" aria-hidden /> Explore graph
272 + </Link>
273 + {memoryGb !== null && (
274 + <Link href={routes.hardwareFit({ memory_gb: memoryGb, quant: '4bit', context: 8192 })} className="inline-flex h-9 items-center gap-1.5 border border-rule px-2.5 text-sm text-ink-2 hover:border-rule-strong hover:text-ink">
275 + <MemoryStick className="size-3.5" aria-hidden /> What can this run?
276 + </Link>
277 + )}
278 + </div>
254 279 </div>
255 280 <div className="shrink-0 text-xs text-ink-3 lg:text-right">
256 281 <QualityMark q={d.quality?.score} label />
@@ -329,9 +354,10 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
329 354 )}
330 355 <section>
331 356 <p className="eyebrow mb-2">Compare</p>
332 <p className="text-sm text-ink-2">
333 <Link href={routes.compare([d.slug])} className="link">Add {d.name} to a comparison →</Link>
334 </p>
357 + <div className="flex flex-wrap items-center gap-3 text-sm">
358 + <CompareButton e={d} />
359 + <Link href={routes.compare([d.slug])} className="link py-2">Open compare →</Link>
360 + </div>
335 361 </section>
336 362 </aside>
337 363 </div>
@@ -446,6 +472,9 @@ export function EntityPage({ d, canonical, related }: { d: EntityDetail; canonic
446 472 </TabPanel>
447 473 )}
448 474
475 + <TabPanel id="history">
476 + <HistoryPanel d={d} asof={asof === 'invalid' ? asofRaw : asof} asofPayload={asof === 'invalid' ? null : asofPayload} claims={claims} property={property} />
477 + </TabPanel>
449 478 <TabPanel id="timeline">
450 479 <TimelineList events={d.timeline ?? []} slug={d.slug} />
451 480 </TabPanel>
added apps/web/src/components/entity/history.tsx +170 −0
@@ -0,0 +1,170 @@
1 +import Link from 'next/link';
2 +import { DataTable, Td, Th } from '@/components/ui/data-table';
3 +import { KeyValue, type KVRow } from '@/components/ui/key-value';
4 +import { SourceCell } from '@/components/ui/provenance';
5 +import { Note } from '@/components/ui/section';
6 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
7 +import { cn } from '@/lib/cn';
8 +import { fmtDate, fmtDateTime, fmtInt, fmtValue } from '@/lib/format';
9 +import { PROSE_KEYS, propertyLabel, routes } from '@/lib/site';
10 +import type { AsOfPayload, Claim, EntityDetail } from '@/lib/types';
11 +import { AsOfPicker } from './asof-picker';
12 +
13 +/* Canonical order for model properties (mirrors SpecTable in blocks.tsx; kept local to avoid touching that file). */
14 +const MODEL_ORDER = ['family', 'version', 'release_date', 'status', 'openness', 'license', 'architecture', 'parameter_count', 'active_parameter_count', 'is_moe', 'context_length', 'max_output_tokens', 'knowledge_cutoff', 'training_data_cutoff', 'modalities', 'modalities_input', 'modalities_output', 'languages', 'tokenizer', 'api_model_id', 'api_alias', 'base_model', 'quantization', 'quant_format', 'file_size_gb', 'deprecation_date', 'retirement_date', 'retirement_tentative', 'hf_repo', 'pipeline_tag', 'official_url', 'model_card_url', 'paper_url', 'repository_url', 'metric.downloads', 'metric.likes'];
15 +const HIDDEN = new Set(['name', 'slug', 'id', 'entity_type']);
16 +const ORDER = new Map(MODEL_ORDER.map((k, i) => [k, i]));
17 +const byCanonical = (a: string, b: string) => (ORDER.get(a) ?? 999) - (ORDER.get(b) ?? 999) || a.localeCompare(b);
18 +
19 +const STATUS_CLS: Record<string, string> = {
20 + current: 'text-positive bg-positive-soft',
21 + superseded: 'text-ink-3 bg-surface-2',
22 + conflicting: 'text-danger bg-danger-soft',
23 + retracted: 'text-warning bg-warning-soft',
24 +};
25 +function ClaimStatus({ status }: { status: string }) {
26 + return <span className={cn('inline-flex items-center whitespace-nowrap rounded-[3px] px-1.5 py-[1px] text-[11px] font-medium leading-4 tracking-wide', STATUS_CLS[status] ?? 'text-ink-2 bg-surface-2')}>{status}</span>;
27 +}
28 +
29 +/* ------------------------------------------------------------------------------------------------------ as of */
30 +
31 +export function AsOfBlock({ d, asof, payload }: { d: EntityDetail; asof: string; payload: AsOfPayload | null }) {
32 + const back = `${routes.entity(d)}?tab=history`;
33 + if (!payload) {
34 + return (
35 + <div className="hairline pt-4">
36 + <Unavailable what={`State as of ${asof}`} reason="Could not load the state for that date — check the date (YYYY-MM-DD, not in the future)." />
37 + <p className="mt-2 text-sm"><Link href={back} className="link">Back to today →</Link></p>
38 + </div>
39 + );
40 + }
41 + const attrs = payload.attributes ?? {};
42 + const keys = Object.keys(attrs).filter((k) => !HIDDEN.has(k) && !PROSE_KEYS.has(k) && attrs[k] !== null && attrs[k] !== undefined && attrs[k] !== '' && !(Array.isArray(attrs[k]) && (attrs[k] as unknown[]).length === 0));
43 + keys.sort(byCanonical);
44 + const rows: KVRow[] = keys.map((k) => ({ key: k, raw: attrs[k] }));
45 + return (
46 + <div className="hairline pt-4">
47 + <div className="flex flex-wrap items-center justify-between gap-2 bg-accent-soft px-3 py-2.5 text-sm">
48 + <p className="text-ink">
49 + <span className="font-medium">Viewing AI Atlas as of {fmtDate(payload.date)}</span>
50 + <span className="text-ink-2"> — attributes exactly as the atlas knew them on that day; later corrections are not shown.</span>
51 + </p>
52 + <Link href={back} className="link shrink-0 py-2">Back to today →</Link>
53 + </div>
54 + {!payload.existed ? (
55 + <EmptyState title={`${d.name} was not yet in AI Atlas on ${fmtDate(payload.date)}`} className="mt-4">
56 + First seen {fmtDate(payload.first_seen_at)}. Nothing is inferred backwards: no attribute is shown for dates before the first observation.
57 + </EmptyState>
58 + ) : (
59 + <div className="mt-4">
60 + <p className="eyebrow mb-2">
61 + Attributes as of {fmtDate(payload.date)} <span className="tnum text-ink-3">{fmtInt(payload.claims?.length ?? 0)} claims in force</span>
62 + </p>
63 + <KeyValue rows={rows} />
64 + </div>
65 + )}
66 + </div>
67 + );
68 +}
69 +
70 +/* ------------------------------------------------------------------------------------------------------ claim history */
71 +
72 +export function ClaimHistory({ d, claims, property }: { d: EntityDetail; claims: Claim[] | null; property?: string }) {
73 + const base = `${routes.entity(d)}?tab=history`;
74 + if (!claims) return <Unavailable what="Claim history" />;
75 + if (!claims.length)
76 + return (
77 + <EmptyState title={property ? `No claims recorded for ${propertyLabel(property)}` : 'No claims recorded yet'}>
78 + {property ? <Link href={base} className="link">Show all properties →</Link> : 'Claims appear when a source states a fact; every later change is kept as a new claim.'}
79 + </EmptyState>
80 + );
81 + const groups = new Map<string, Claim[]>();
82 + for (const c of claims) {
83 + const arr = groups.get(c.property);
84 + if (arr) arr.push(c);
85 + else groups.set(c.property, [c]);
86 + }
87 + const keys = [...groups.keys()].sort(byCanonical);
88 + const conflicting = claims.filter((c) => c.status === 'conflicting').length;
89 + return (
90 + <div className="space-y-8">
91 + <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-ink-3">
92 + <span className="tnum">{fmtInt(claims.length)} claims · {fmtInt(keys.length)} properties</span>
93 + {conflicting > 0 && <span className="font-medium text-danger">{fmtInt(conflicting)} conflicting</span>}
94 + {property && (
95 + <Link href={base} className="link">
96 + Show all properties
97 + </Link>
98 + )}
99 + </div>
100 + {keys.map((k) => {
101 + const rows = groups.get(k) ?? [];
102 + return (
103 + <section key={k} id={`h-${k}`} className="scroll-mt-20">
104 + <h3 className="mb-2 flex flex-wrap items-baseline gap-x-2">
105 + <Link href={`${base}&property=${encodeURIComponent(k)}`} className="text-sm font-semibold text-ink hover:text-accent" aria-current={property === k ? 'true' : undefined}>
106 + {propertyLabel(k)}
107 + </Link>
108 + <span className="mono text-[11px] text-ink-3">{k}</span>
109 + <span className="tnum text-xs text-ink-3">{fmtInt(rows.length)}</span>
110 + {rows.some((c) => c.status === 'conflicting') && <span className="text-xs font-medium text-danger">conflicting claims</span>}
111 + </h3>
112 + <DataTable compact caption={`Claim history for ${propertyLabel(k)}`}>
113 + <thead>
114 + <tr>
115 + <Th>Value</Th>
116 + <Th>Valid from → to</Th>
117 + <Th>Status</Th>
118 + <Th>Source</Th>
119 + <Th>Confidence</Th>
120 + <Th>Extractor</Th>
121 + </tr>
122 + </thead>
123 + <tbody>
124 + {rows.map((c) => (
125 + <tr key={c.id} className={c.status === 'conflicting' ? 'border-l-2 border-danger' : undefined}>
126 + <Td primary className={c.status === 'conflicting' ? 'pl-2 md:pl-2' : undefined}>
127 + <span className="tnum">{fmtValue(c.value, c.property)}</span>
128 + {c.unit && !/parameter_count|context_length|max_output_tokens|memory_gb|file_size_gb/.test(c.property) && <span className="text-xs text-ink-3"> {c.unit}</span>}
129 + </Td>
130 + <Td label="Valid" className="tnum text-xs text-ink-2">
131 + <time dateTime={c.valid_from}>{fmtDateTime(c.valid_from)}</time>
132 + <span className="text-ink-3"> → </span>
133 + {c.valid_to ? <time dateTime={c.valid_to}>{fmtDateTime(c.valid_to)}</time> : <span className="font-medium text-positive">current</span>}
134 + </Td>
135 + <Td label="Status"><ClaimStatus status={c.status} /></Td>
136 + <Td label="Source"><SourceCell url={c.source_url} tier={c.tier} name={c.source_name} /></Td>
137 + <Td label="Confidence" className={cn('text-xs', c.confidence === 'conflicted' && 'text-danger', c.confidence === 'low' && 'text-warning', !['conflicted', 'low'].includes(c.confidence) && 'text-ink-2')}>{c.confidence}</Td>
138 + <Td label="Extractor" className="mono text-xs text-ink-3">{c.extractor}</Td>
139 + </tr>
140 + ))}
141 + </tbody>
142 + </DataTable>
143 + </section>
144 + );
145 + })}
146 + <Note>
147 + Claims are temporal and append-only: a new observation closes the previous claim (<span className="mono">valid_to</span>) instead of overwriting it. Conflicting claims from different sources are kept side by side and flagged — never averaged. <Link href="/methodology" className="link">Methodology →</Link>
148 + </Note>
149 + </div>
150 + );
151 +}
152 +
153 +/* ------------------------------------------------------------------------------------------------------ panel */
154 +
155 +export function HistoryPanel({ d, asof, asofPayload, claims, property }: { d: EntityDetail; asof?: string; asofPayload: AsOfPayload | null; claims: Claim[] | null; property?: string }) {
156 + return (
157 + <div className="space-y-10">
158 + <section>
159 + <p className="eyebrow mb-2">As of</p>
160 + <p className="mb-3 max-w-2xl text-sm text-ink-2">Rewind the record: see this entity's attributes exactly as AI Atlas knew them on a given day.</p>
161 + <AsOfPicker value={asof} />
162 + {asof && <div className="mt-4"><AsOfBlock d={d} asof={asof} payload={asofPayload} /></div>}
163 + </section>
164 + <section>
165 + <p className="eyebrow mb-2">Claim history{property ? <> · {propertyLabel(property)}</> : null}</p>
166 + <ClaimHistory d={d} claims={claims} property={property} />
167 + </section>
168 + </div>
169 + );
170 +}
added apps/web/src/components/graph/graph-explorer.tsx +167 −0
@@ -0,0 +1,167 @@
1 +'use client';
2 +import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from 'd3-force';
3 +import { useRouter } from 'next/navigation';
4 +import { useEffect, useMemo, useState } from 'react';
5 +import { cn } from '@/lib/cn';
6 +import { predicateLabel, routes, TYPE_COLOR_KEY, typeLabel } from '@/lib/site';
7 +import type { GraphEdge, GraphNode } from '@/lib/types';
8 +
9 +type SimNode = SimulationNodeDatum & GraphNode & { degree: number; isRoot: boolean };
10 +type SimLink = SimulationLinkDatum<SimNode> & { predicate: string };
11 +
12 +const W = 960;
13 +const H = 640;
14 +const LABEL_LIMIT = 40;
15 +const EDGE_LABEL_LIMIT = 40;
16 +
17 +function colorOf(type: string): string {
18 + return `var(--type-${TYPE_COLOR_KEY[type] ?? 'tool'})`;
19 +}
20 +function short(s: string, n = 26): string {
21 + return s.length > n ? `${s.slice(0, n - 1)}…` : s;
22 +}
23 +
24 +/**
25 + * Force-directed neighbourhood graph (d3-force run for a fixed number of ticks, deterministic seed, no animation).
26 + * Server render: the SVG frame + legend only; the layout is computed on the client after mount so markup stays stable.
27 + * Click / Enter → the entity page; hover / focus highlights the neighbourhood and reveals edge labels.
28 + */
29 +export function GraphExplorer({ nodes, edges, rootId }: { nodes: GraphNode[]; edges: GraphEdge[]; rootId: string }) {
30 + const router = useRouter();
31 + const [mounted, setMounted] = useState(false);
32 + const [hover, setHover] = useState<string | null>(null);
33 + useEffect(() => setMounted(true), []);
34 +
35 + const layout = useMemo(() => {
36 + if (!mounted) return null;
37 + const ids = new Set(nodes.map((n) => n.id));
38 + const degree = new Map<string, number>();
39 + const links: SimLink[] = [];
40 + for (const e of edges) {
41 + if (!ids.has(e.source) || !ids.has(e.target)) continue;
42 + degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
43 + degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
44 + links.push({ source: e.source, target: e.target, predicate: e.predicate });
45 + }
46 + // Deterministic seed: nodes on a ring ordered by type, root at the centre.
47 + const sim: SimNode[] = nodes.map((n, i) => {
48 + const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
49 + const isRoot = n.id === rootId;
50 + return { ...n, degree: degree.get(n.id) ?? 0, isRoot, x: isRoot ? W / 2 : W / 2 + Math.cos(ang) * 220, y: isRoot ? H / 2 : H / 2 + Math.sin(ang) * 220, fx: isRoot ? W / 2 : undefined, fy: isRoot ? H / 2 : undefined };
51 + });
52 + const byId = new Map(sim.map((n) => [n.id, n]));
53 + const radius = (n: SimNode) => (n.isRoot ? 14 : 5 + Math.min(6, Math.sqrt(n.degree)));
54 + const s = forceSimulation<SimNode>(sim)
55 + .force('link', forceLink<SimNode, SimLink>(links).id((d) => d.id).distance((l) => {
56 + const a = l.source as SimNode;
57 + const b = l.target as SimNode;
58 + const hub = Math.max(a.degree, b.degree);
59 + return 70 + Math.min(90, hub * 3) + (a.isRoot || b.isRoot ? 60 : 0);
60 + }).strength(0.6))
61 + .force('charge', forceManyBody<SimNode>().strength((d) => (d.isRoot ? -900 : -220)))
62 + .force('center', forceCenter(W / 2, H / 2))
63 + .force('collide', forceCollide<SimNode>().radius((d) => radius(d) + 14).iterations(2))
64 + .stop();
65 + for (let i = 0; i < 300; i++) s.tick();
66 + // Clamp into the viewBox.
67 + for (const n of sim) {
68 + n.x = Math.max(24, Math.min(W - 24, n.x ?? W / 2));
69 + n.y = Math.max(24, Math.min(H - 24, n.y ?? H / 2));
70 + }
71 + const neighbours = new Map<string, Set<string>>();
72 + for (const l of links) {
73 + const a = (l.source as SimNode).id;
74 + const b = (l.target as SimNode).id;
75 + (neighbours.get(a) ?? neighbours.set(a, new Set()).get(a)!).add(b);
76 + (neighbours.get(b) ?? neighbours.set(b, new Set()).get(b)!).add(a);
77 + }
78 + return { sim, links, byId, radius, neighbours };
79 + }, [mounted, nodes, edges, rootId]);
80 +
81 + const showAllLabels = nodes.length <= LABEL_LIMIT;
82 + const showAllEdgeLabels = edges.length <= EDGE_LABEL_LIMIT;
83 +
84 + const go = (n: GraphNode) => router.push(routes.entity(n));
85 + const active = (id: string) => !hover || hover === id || (layout?.neighbours.get(hover)?.has(id) ?? false);
86 +
87 + return (
88 + <div className="relative">
89 + <svg viewBox={`0 0 ${W} ${H}`} className="block h-auto w-full border border-rule bg-surface" role="img" aria-label="Relationship graph" onMouseLeave={() => setHover(null)}>
90 + {!layout && (
91 + <text x={W / 2} y={H / 2} textAnchor="middle" fontSize={14} fill="var(--ink-3)">
92 + Laying out {nodes.length} nodes…
93 + </text>
94 + )}
95 + {layout && (
96 + <>
97 + <g>
98 + {layout.links.map((l, i) => {
99 + const a = l.source as SimNode;
100 + const b = l.target as SimNode;
101 + const on = !hover || hover === a.id || hover === b.id;
102 + const label = showAllEdgeLabels || (hover && (hover === a.id || hover === b.id));
103 + const mx = ((a.x ?? 0) + (b.x ?? 0)) / 2;
104 + const my = ((a.y ?? 0) + (b.y ?? 0)) / 2;
105 + return (
106 + <g key={i} opacity={on ? 1 : 0.12}>
107 + <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke={on && hover ? 'var(--accent)' : 'var(--rule-strong)'} strokeWidth={on && hover ? 1.4 : 1} />
108 + {label && (
109 + <text x={mx} y={my - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="pointer-events-none select-none">
110 + {predicateLabel(l.predicate, 'out')}
111 + </text>
112 + )}
113 + </g>
114 + );
115 + })}
116 + </g>
117 + <g>
118 + {layout.sim.map((n) => {
119 + const r = layout.radius(n);
120 + const on = active(n.id);
121 + const label = n.isRoot || showAllLabels || (hover && (hover === n.id || layout.neighbours.get(hover)?.has(n.id)));
122 + const color = colorOf(n.entity_type);
123 + return (
124 + <g
125 + key={n.id}
126 + transform={`translate(${n.x},${n.y})`}
127 + opacity={on ? 1 : 0.2}
128 + tabIndex={0}
129 + role="link"
130 + aria-label={`${n.name} (${typeLabel(n.entity_type)})`}
131 + className="cursor-pointer outline-none focus-visible:[&>circle]:stroke-accent"
132 + onMouseEnter={() => setHover(n.id)}
133 + onFocus={() => setHover(n.id)}
134 + onBlur={() => setHover(null)}
135 + onClick={() => go(n)}
136 + onKeyDown={(e) => {
137 + if (e.key === 'Enter' || e.key === ' ') {
138 + e.preventDefault();
139 + go(n);
140 + }
141 + }}
142 + >
143 + <circle r={Math.max(16, r + 10)} fill="transparent" data-hit="" />
144 + {n.isRoot && <circle r={r + 6} fill="none" stroke={color} strokeWidth={1} opacity={0.5} />}
145 + <circle r={r} fill={color} stroke="var(--canvas)" strokeWidth={1.5} />
146 + {label && (
147 + <text x={0} y={r + 11} textAnchor="middle" fontSize={n.isRoot ? 12 : 10} fontWeight={n.isRoot ? 600 : 400} fill={n.isRoot ? 'var(--ink)' : 'var(--ink-2)'} className="pointer-events-none select-none" style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
148 + {short(n.name, n.isRoot ? 40 : 26)}
149 + </text>
150 + )}
151 + </g>
152 + );
153 + })}
154 + </g>
155 + </>
156 + )}
157 + </svg>
158 + {layout && hover && layout.byId.get(hover) && (
159 + <p className="pointer-events-none absolute left-2 top-2 max-w-[70%] truncate bg-canvas/90 px-2 py-1 text-xs text-ink-2 backdrop-blur">
160 + <span className="font-medium text-ink">{layout.byId.get(hover)!.name}</span> · {typeLabel(layout.byId.get(hover)!.entity_type)}
161 + {layout.byId.get(hover)!.organization_name ? ` · ${layout.byId.get(hover)!.organization_name}` : ''} · {layout.byId.get(hover)!.degree} link{layout.byId.get(hover)!.degree === 1 ? '' : 's'}
162 + </p>
163 + )}
164 + {!showAllLabels && <p className={cn('mt-1 text-xs text-ink-3')}>Labels are shown on hover or focus beyond {LABEL_LIMIT} nodes; the list below names every node.</p>}
165 + </div>
166 + );
167 +}
added apps/web/src/components/hardware/fit-form.tsx +79 −0
@@ -0,0 +1,79 @@
1 +import { Cpu } from 'lucide-react';
2 +import { cn } from '@/lib/cn';
3 +
4 +/**
5 + * GET form for /hardware/fit — no JavaScript needed. Memory presets are grouped by platform; a free numeric field
6 + * overrides the preset when filled. Quantization and context are plain selects (the API accepts 4bit|8bit|fp16 and
7 + * any positive context; we offer the three common windows).
8 + */
9 +export const APPLE_PRESETS = [16, 24, 32, 36, 48, 64, 96, 128, 192, 256, 512];
10 +export const NVIDIA_PRESETS = [24, 32, 80, 141, 192];
11 +export const QUANTS = [
12 + { value: '4bit', label: '4-bit (≈ 0.5 byte / param)' },
13 + { value: '8bit', label: '8-bit (1 byte / param)' },
14 + { value: 'fp16', label: 'fp16 / bf16 (2 bytes / param)' },
15 +];
16 +export const CONTEXTS = [
17 + { value: '8192', label: '8k tokens' },
18 + { value: '32768', label: '32k tokens' },
19 + { value: '131072', label: '128k tokens' },
20 +];
21 +
22 +export function FitForm({ memory, quant, context, className }: { memory?: string; quant?: string; context?: string; className?: string }) {
23 + const cls = 'h-11 w-full border border-rule bg-surface px-2.5 text-[15px] text-ink focus:border-accent focus:outline-none';
24 + const presetSelected = memory && [...APPLE_PRESETS, ...NVIDIA_PRESETS].includes(Number(memory)) ? memory : '';
25 + return (
26 + <form action="/hardware/fit" method="get" className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-[1.4fr_1fr_1.1fr_1fr_auto] lg:items-end', className)}>
27 + <label className="block min-w-0">
28 + <span className="eyebrow block pb-1">Device memory (preset)</span>
29 + <select name="memory_gb" defaultValue={presetSelected} className={cls} aria-describedby="fit-memory-hint">
30 + <option value="">Choose a preset…</option>
31 + <optgroup label="Apple silicon (unified memory)">
32 + {APPLE_PRESETS.map((g) => (
33 + <option key={`a${g}`} value={g}>
34 + {g} GB
35 + </option>
36 + ))}
37 + </optgroup>
38 + <optgroup label="NVIDIA (VRAM)">
39 + {NVIDIA_PRESETS.map((g) => (
40 + <option key={`n${g}`} value={g}>
41 + {g} GB
42 + </option>
43 + ))}
44 + </optgroup>
45 + </select>
46 + </label>
47 + <label className="block min-w-0">
48 + <span className="eyebrow block pb-1">…or any memory (GB)</span>
49 + <input name="memory_custom" inputMode="decimal" pattern="[0-9]*\.?[0-9]*" defaultValue={presetSelected ? '' : memory ?? ''} placeholder="e.g. 20" className={cls} />
50 + </label>
51 + <label className="block min-w-0">
52 + <span className="eyebrow block pb-1">Quantization</span>
53 + <select name="quant" defaultValue={quant ?? '4bit'} className={cls}>
54 + {QUANTS.map((q) => (
55 + <option key={q.value} value={q.value}>
56 + {q.label}
57 + </option>
58 + ))}
59 + </select>
60 + </label>
61 + <label className="block min-w-0">
62 + <span className="eyebrow block pb-1">Context window</span>
63 + <select name="context" defaultValue={context ?? '8192'} className={cls}>
64 + {CONTEXTS.map((c) => (
65 + <option key={c.value} value={c.value}>
66 + {c.label}
67 + </option>
68 + ))}
69 + </select>
70 + </label>
71 + <button type="submit" className="inline-flex h-11 items-center justify-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 sm:col-span-2 lg:col-span-1">
72 + <Cpu className="size-4" aria-hidden /> Estimate fit
73 + </button>
74 + <p id="fit-memory-hint" className="text-xs text-ink-3 sm:col-span-2 lg:col-span-5">
75 + The custom field wins over the preset when both are filled. Estimates reserve 2 GB for the OS and framework.
76 + </p>
77 + </form>
78 + );
79 +}
modified apps/web/src/components/listing/generic-listing.tsx +3 −3
@@ -14,7 +14,7 @@ const LIMIT = 40;
14 14 * Generic paged listing for a type with light filters (q, org, sort). `fetch` defaults to /explore/<type>;
15 15 * pass a custom fetcher for typed endpoints (/papers, /hardware). Renders `EntityRow`s — dense, mobile-safe.
16 16 */
17 export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode }) {
17 +export async function GenericListing({ type, basePath, title, eyebrow, lede, searchParams, fetch, sorts = [{ value: 'updated', label: 'Recently updated' }, { value: 'name', label: 'Name' }], extraFields = [], children, rowTrailing, headerAside }: { type: string; basePath: string; title: string; eyebrow: string; lede?: string; searchParams: Record<string, string | undefined>; fetch?: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>; sorts?: { value: string; label: string }[]; extraFields?: Parameters<typeof FilterBar>[0]['fields']; children?: ReactNode; /** Optional per-row trailing node (e.g. a CompareButton). */ rowTrailing?: (e: EntitySummary) => ReactNode; /** Extra content in the page header aside, next to the total. */ headerAside?: ReactNode }) {
18 18 const current: Record<string, string | undefined> = {};
19 19 for (const k of ['q', 'org', 'sort', 'category', 'kind', 'manufacturer', 'since', 'offset']) if (searchParams[k]) current[k] = searchParams[k];
20 20 const offset = Math.max(0, Number(current.offset) || 0);
@@ -23,7 +23,7 @@ export async function GenericListing({ type, basePath, title, eyebrow, lede, sea
23 23 const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);
24 24 return (
25 25 <Container>
26 <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page ? <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p> : undefined}>
26 + <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}>
27 27 <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...extraFields]} sort={{ value: current.sort ?? sorts[0]?.value, options: sorts }} />
28 28 </PageHeader>
29 29 {children}
@@ -36,7 +36,7 @@ export async function GenericListing({ type, basePath, title, eyebrow, lede, sea
36 36 <>
37 37 <ul className="border-t border-rule">
38 38 {page.items.map((e) => (
39 <EntityRow key={e.id} e={e} showType={e.entity_type !== type} />
39 + <EntityRow key={e.id} e={e} showType={e.entity_type !== type} trailing={rowTrailing?.(e)} />
40 40 ))}
41 41 </ul>
42 42 <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
added apps/web/src/components/listing/typed-listing.tsx +133 −0
@@ -0,0 +1,133 @@
1 +import type { ReactNode } from 'react';
2 +import { CompareButton } from '@/components/compare/compare-button';
3 +import { CompareTrayBar } from '@/components/compare/compare-tray-bar';
4 +import { FilterBar, type FilterField } from '@/components/listing/filters';
5 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
6 +import { Pagination, withParams } from '@/components/ui/pagination';
7 +import { Container, Note, PageHeader } from '@/components/ui/section';
8 +import { EmptyState, Unavailable } from '@/components/ui/unavailable';
9 +import { safe } from '@/lib/api';
10 +import { fmtInt } from '@/lib/format';
11 +import type { EntitySummary, Page } from '@/lib/types';
12 +
13 +/**
14 + * Type-specific paged table listing (papers, frameworks, datasets, tools…). Server component: a GET `FilterBar` drives
15 + * the URL, the page fetches once, rows render through column definitions. Stacks under 768 px via `.data-table.stack`
16 + * (every cell carries its label). `compare` adds a per-row CompareButton.
17 + */
18 +export type Column = {
19 + key: string;
20 + label: string;
21 + num?: boolean;
22 + primary?: boolean;
23 + wide?: boolean;
24 + hideStack?: boolean;
25 + className?: string;
26 + render: (e: EntitySummary) => ReactNode;
27 +};
28 +
29 +const LIMIT = 40;
30 +const PARAM_KEYS = ['q', 'org', 'sort', 'order', 'category', 'kind', 'since', 'until', 'manufacturer', 'offset'];
31 +
32 +export async function TypedListing({
33 + title,
34 + eyebrow,
35 + lede,
36 + basePath,
37 + searchParams,
38 + fetch,
39 + columns,
40 + sorts,
41 + filters = [],
42 + emptyTitle,
43 + emptyHint,
44 + note,
45 + compare = false,
46 + headerAside,
47 + children,
48 +}: {
49 + title: string;
50 + eyebrow: string;
51 + lede?: string;
52 + basePath: string;
53 + searchParams: Record<string, string | undefined>;
54 + fetch: (q: Record<string, string | number | undefined>) => Promise<Page<EntitySummary>>;
55 + columns: Column[];
56 + sorts: { value: string; label: string }[];
57 + filters?: FilterField[];
58 + emptyTitle?: string;
59 + emptyHint?: ReactNode;
60 + note?: ReactNode;
61 + compare?: boolean;
62 + headerAside?: ReactNode;
63 + children?: ReactNode;
64 +}) {
65 + const current: Record<string, string | undefined> = {};
66 + for (const k of PARAM_KEYS) if (searchParams[k]) current[k] = searchParams[k];
67 + const offset = Math.max(0, Number(current.offset) || 0);
68 + const sort = current.sort ?? sorts[0]?.value;
69 + const page = await safe(fetch({ ...current, sort, limit: LIMIT, offset }));
70 + const href = (patch: Record<string, string | number | undefined | null>) => withParams(basePath, current, patch);
71 + const cols = columns.length + (compare ? 1 : 0);
72 + return (
73 + <Container wide>
74 + <PageHeader eyebrow={eyebrow} title={title} lede={lede} aside={page || headerAside ? <div className="flex flex-col items-start gap-2 md:items-end">{page && <p className="tnum text-sm text-ink-3">{fmtInt(page.total)} total</p>}{headerAside}</div> : undefined}>
75 + <FilterBar action={basePath} className="mt-6" resetHref={basePath} fields={[{ kind: 'text', name: 'q', label: 'Name', value: current.q, placeholder: 'Search by name' }, ...filters]} sort={{ value: sort, options: sorts }} />
76 + </PageHeader>
77 + {children}
78 + <div className="pb-16">
79 + {!page ? (
80 + <Unavailable what={title} />
81 + ) : page.items.length === 0 && !offset ? (
82 + <EmptyState title={emptyTitle ?? `No ${title.toLowerCase()} match`}>{emptyHint ?? 'Try another name or remove filters.'}</EmptyState>
83 + ) : (
84 + <>
85 + <DataTable caption={title}>
86 + <thead>
87 + <tr>
88 + {columns.map((c) => (
89 + <Th key={c.key} num={c.num}>
90 + {c.label}
91 + </Th>
92 + ))}
93 + {compare && <Th className="w-24"><span className="sr-only">Compare</span></Th>}
94 + </tr>
95 + </thead>
96 + <tbody>
97 + {page.items.length === 0 && <EmptyRow cols={cols}>No rows on this page.</EmptyRow>}
98 + {page.items.map((e) => (
99 + <tr key={e.id}>
100 + {columns.map((c) => (
101 + <Td key={c.key} label={c.primary ? undefined : c.label} num={c.num} primary={c.primary} wide={c.wide} hideStack={c.hideStack} className={c.className}>
102 + {c.render(e)}
103 + </Td>
104 + ))}
105 + {compare && (
106 + <Td className="text-right">
107 + <CompareButton e={e} size="sm" />
108 + </Td>
109 + )}
110 + </tr>
111 + ))}
112 + </tbody>
113 + </DataTable>
114 + <Pagination total={page.total} limit={LIMIT} offset={offset} makeHref={(o) => href({ offset: o || undefined })} className="mt-4" />
115 + {note && <Note className="mt-3">{note}</Note>}
116 + </>
117 + )}
118 + </div>
119 + {compare && <CompareTrayBar />}
120 + </Container>
121 + );
122 +}
123 +
124 +/** Shared cell helpers. */
125 +export function Dash() {
126 + return <span className="text-ink-3">—</span>;
127 +}
128 +export function str(v: unknown): string | null {
129 + return typeof v === 'string' && v.trim() ? v : null;
130 +}
131 +export function list(v: unknown): string[] {
132 + return Array.isArray(v) ? v.filter((x) => x !== null && x !== undefined).map(String) : [];
133 +}
added apps/web/src/components/prices/movers.tsx +86 −0
@@ -0,0 +1,86 @@
1 +import { ExternalLink } from 'lucide-react';
2 +import { EntityLink } from '@/components/ui/entity';
3 +import { DataTable, EmptyRow, Td, Th } from '@/components/ui/data-table';
4 +import { Note } from '@/components/ui/section';
5 +import { fmtDateTime, fmtAgo, fmtUsdPerM, num } from '@/lib/format';
6 +import type { ChangeEvent } from '@/lib/types';
7 +
8 +type PriceValue = { input_per_mtok?: unknown; output_per_mtok?: unknown } | null | undefined;
9 +
10 +function pv(v: unknown): { input: number | null; output: number | null } {
11 + const o = (v && typeof v === 'object' ? v : {}) as PriceValue;
12 + return { input: num(o?.input_per_mtok), output: num(o?.output_per_mtok) };
13 +}
14 +
15 +function host(url: string): string {
16 + try {
17 + return new URL(url).hostname.replace(/^www\./, '');
18 + } catch {
19 + return 'source';
20 + }
21 +}
22 +
23 +/** Old → new price cell: strike-through old, bold new, direction sign. Amber = money. */
24 +function Move({ from, to }: { from: number | null; to: number | null }) {
25 + if (from === null && to === null) return <span className="text-ink-3">—</span>;
26 + const dir = from !== null && to !== null ? (to < from ? '↓' : to > from ? '↑' : '=') : '';
27 + return (
28 + <span className="tnum inline-flex items-center gap-1.5">
29 + <span className="text-ink-3 line-through decoration-ink-3/60">{fmtUsdPerM(from)}</span>
30 + <span aria-hidden className="text-ink-3">→</span>
31 + <span className={dir === '=' ? 'text-ink-2' : 'font-semibold text-accent-2'}>{fmtUsdPerM(to)}</span>
32 + {dir && dir !== '=' && <span className={dir === '↓' ? 'text-positive' : 'text-danger'} aria-label={dir === '↓' ? 'decrease' : 'increase'}>{dir}</span>}
33 + </span>
34 + );
35 +}
36 +
37 +/** PRICE_CHANGED events as a dense table: model · provider · input move · output move · when · source. */
38 +export function PriceMovers({ movers }: { movers: ChangeEvent[] }) {
39 + const rows = movers.filter((e) => e.event_type === 'PRICE_CHANGED' || e.category === 'price');
40 + return (
41 + <>
42 + <DataTable caption="Recent price changes">
43 + <thead>
44 + <tr>
45 + <Th>Model</Th>
46 + <Th>Provider</Th>
47 + <Th>Input / 1M</Th>
48 + <Th>Output / 1M</Th>
49 + <Th>Observed</Th>
50 + <Th>Source</Th>
51 + </tr>
52 + </thead>
53 + <tbody>
54 + {rows.length === 0 && <EmptyRow cols={6}>No price change recorded in this window. The first observation of a price is not a change.</EmptyRow>}
55 + {rows.map((e) => {
56 + const o = pv(e.old_value);
57 + const n = pv(e.new_value);
58 + const provider = typeof e.meta?.provider === 'string' ? e.meta.provider : null;
59 + return (
60 + <tr key={e.id}>
61 + <Td primary>
62 + {e.entity ? <EntityLink e={e.entity} /> : <span className="text-ink-3">—</span>}
63 + {e.entity?.organization && <span className="ml-2 text-xs text-ink-3">{e.entity.organization.name}</span>}
64 + </Td>
65 + <Td label="Provider" className="text-ink-2">{provider ?? <span className="text-ink-3">—</span>}</Td>
66 + <Td label="Input / 1M"><Move from={o.input} to={n.input} /></Td>
67 + <Td label="Output / 1M"><Move from={o.output} to={n.output} /></Td>
68 + <Td label="Observed" className="text-ink-2" title={fmtDateTime(e.observed_at)}>{fmtAgo(e.observed_at)}</Td>
69 + <Td label="Source">
70 + {e.source_url ? (
71 + <a href={e.source_url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-xs text-ink-2 hover:text-accent">
72 + {host(e.source_url)} <ExternalLink className="size-3" aria-hidden />
73 + </a>
74 + ) : (
75 + <span className="text-ink-3">—</span>
76 + )}
77 + </Td>
78 + </tr>
79 + );
80 + })}
81 + </tbody>
82 + </DataTable>
83 + {rows.length > 0 && <Note className="mt-3">Movers are PRICE_CHANGED events emitted when a provider's published price for a model differs from the previous observation. Green arrow = cheaper, red = dearer.</Note>}
84 + </>
85 + );
86 +}
added apps/web/src/components/prices/scale-toggle.tsx +38 −0
@@ -0,0 +1,38 @@
1 +'use client';
2 +import { type ReactNode, useState } from 'react';
3 +import { cn } from '@/lib/cn';
4 +
5 +/**
6 + * Linear / log axis switch for a server-rendered chart: both variants are rendered by the server (SEO, no client
7 + * fetch); this only toggles which one is visible and mirrors the choice into `?scale=` with `replaceState`
8 + * (no navigation, no re-render of the page).
9 + */
10 +export function ScaleToggle({ linear, log, initial = 'linear', className }: { linear: ReactNode; log: ReactNode; initial?: 'linear' | 'log'; className?: string }) {
11 + const [scale, setScale] = useState<'linear' | 'log'>(initial);
12 + const set = (s: 'linear' | 'log') => {
13 + setScale(s);
14 + try {
15 + const url = new URL(window.location.href);
16 + if (s === 'log') url.searchParams.set('scale', 'log');
17 + else url.searchParams.delete('scale');
18 + window.history.replaceState(null, '', url.toString());
19 + } catch {
20 + /* ignore */
21 + }
22 + };
23 + const btn = (s: 'linear' | 'log', label: string) => (
24 + <button type="button" onClick={() => set(s)} aria-pressed={scale === s} className={cn('h-9 border px-3 text-xs font-medium transition-colors', scale === s ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
25 + {label}
26 + </button>
27 + );
28 + return (
29 + <div className={className}>
30 + <div className="mb-2 flex items-center gap-1" role="group" aria-label="Y axis scale">
31 + {btn('linear', 'Linear')}
32 + {btn('log', 'Log scale')}
33 + </div>
34 + <div hidden={scale !== 'linear'}>{linear}</div>
35 + <div hidden={scale !== 'log'}>{log}</div>
36 + </div>
37 + );
38 +}
added apps/web/src/components/timeline/chip-row.tsx +23 −0
@@ -0,0 +1,23 @@
1 +import Link from 'next/link';
2 +import { cn } from '@/lib/cn';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +export type ChipItem = { href: string; label: string; count?: number | string | null; active?: boolean };
6 +
7 +/**
8 + * Horizontal row of link chips (years, categories, ranges). Scrolls on mobile without a visible scrollbar,
9 + * wraps on desktop. Server component — selection lives in the URL.
10 + */
11 +export function ChipRow({ items, label, className }: { items: ChipItem[]; label: string; className?: string }) {
12 + if (!items.length) return null;
13 + return (
14 + <nav aria-label={label} className={cn('no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0', className)}>
15 + {items.map((it) => (
16 + <Link key={it.href + it.label} href={it.href} aria-current={it.active ? 'true' : undefined} className={cn('inline-flex h-9 shrink-0 items-center gap-1.5 border px-2.5 text-sm transition-colors', it.active ? 'border-ink bg-ink text-canvas' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
17 + <span>{it.label}</span>
18 + {it.count !== undefined && it.count !== null && <span className={cn('tnum text-[11px]', it.active ? 'text-canvas/70' : 'text-ink-3')}>{fmtInt(it.count)}</span>}
19 + </Link>
20 + ))}
21 + </nav>
22 + );
23 +}
added apps/web/src/lib/admin/actions.ts +150 −0
@@ -0,0 +1,150 @@
1 +'use server';
2 +import { revalidatePath } from 'next/cache';
3 +import { redirect } from 'next/navigation';
4 +import { ApiError } from '@/lib/api';
5 +import { AdminAuthError, adminApi, claimHistory } from './admin-api';
6 +import { clearAdminToken, setAdminToken } from './session';
7 +
8 +/**
9 + * Server actions for the admin console. Each mutating action calls the API with the cookie token, revalidates the
10 + * section and redirects back with a `?notice=` message (no client state, works without JS).
11 + */
12 +
13 +function describe(e: unknown): string {
14 + if (e instanceof AdminAuthError) return 'Session expired — sign in again.';
15 + if (e instanceof ApiError) return e.detail ? `API ${e.status}: ${e.detail}` : e.message;
16 + return (e as Error)?.message ?? 'Unknown error';
17 +}
18 +
19 +function back(path: string, notice: string, ok = true): never {
20 + const p = new URLSearchParams();
21 + p.set('notice', notice);
22 + if (!ok) p.set('level', 'error');
23 + redirect(`${path}${path.includes('?') ? '&' : '?'}${p.toString()}`);
24 +}
25 +
26 +async function run(path: string, okMsg: (r: unknown) => string, fn: () => Promise<unknown>): Promise<never> {
27 + let msg: string;
28 + let ok = true;
29 + try {
30 + const r = await fn();
31 + msg = okMsg(r);
32 + } catch (e) {
33 + if (e instanceof AdminAuthError) redirect('/admin?expired=1');
34 + msg = describe(e);
35 + ok = false;
36 + }
37 + revalidatePath(path.split('?')[0] ?? path);
38 + back(path, msg, ok);
39 +}
40 +
41 +// ------------------------------------------------------------------------------------------------------------ auth
42 +export async function loginAction(_prev: { error: string | null } | undefined, formData: FormData): Promise<{ error: string | null }> {
43 + const token = String(formData.get('token') ?? '').trim();
44 + if (!token) return { error: 'Enter the admin token.' };
45 + try {
46 + await adminApi.overview(token);
47 + } catch (e) {
48 + if (e instanceof AdminAuthError) return { error: 'Token rejected by the API.' };
49 + return { error: `API unreachable: ${describe(e)}` };
50 + }
51 + await setAdminToken(token);
52 + redirect('/admin/overview');
53 +}
54 +
55 +export async function logoutAction(): Promise<void> {
56 + await clearAdminToken();
57 + redirect('/admin?signed_out=1');
58 +}
59 +
60 +// ------------------------------------------------------------------------------------------------------------ connectors
61 +export async function runConnectorAction(formData: FormData): Promise<void> {
62 + const name = String(formData.get('name') ?? '');
63 + const ret = String(formData.get('return') ?? '/admin/connectors');
64 + await run(ret, () => `Run queued for ${name} — the scheduler picks it up on its next tick.`, () => adminApi.runConnector(name, true));
65 +}
66 +
67 +export async function toggleConnectorAction(formData: FormData): Promise<void> {
68 + const name = String(formData.get('name') ?? '');
69 + const enabled = String(formData.get('enabled') ?? '') === 'true';
70 + const ret = String(formData.get('return') ?? '/admin/connectors');
71 + await run(ret, () => `${name} ${enabled ? 'enabled' : 'disabled'}.`, () => adminApi.patchConnector(name, { enabled }));
72 +}
73 +
74 +// ------------------------------------------------------------------------------------------------------------ jobs
75 +export async function retryJobAction(formData: FormData): Promise<void> {
76 + const id = String(formData.get('id') ?? '');
77 + const ret = String(formData.get('return') ?? '/admin/jobs');
78 + await run(ret, () => `Job ${id} re-queued.`, () => adminApi.retryJob(id));
79 +}
80 +
81 +export async function requeueDeadAction(formData: FormData): Promise<void> {
82 + const ret = String(formData.get('return') ?? '/admin/jobs');
83 + await run(
84 + ret,
85 + (r) => {
86 + const n = (r as { requeued?: number; count?: number } | null)?.requeued ?? (r as { count?: number } | null)?.count;
87 + return n === undefined ? 'Dead jobs re-queued.' : `${n} dead job${n === 1 ? '' : 's'} re-queued.`;
88 + },
89 + () => adminApi.requeueDead(),
90 + );
91 +}
92 +
93 +// ------------------------------------------------------------------------------------------------------------ review
94 +export async function reviewAction(formData: FormData): Promise<void> {
95 + const id = String(formData.get('id') ?? '');
96 + const action = String(formData.get('action') ?? '') as 'approve' | 'reject';
97 + const ret = String(formData.get('return') ?? '/admin/review');
98 + if (action !== 'approve' && action !== 'reject') back(ret, 'Unknown action.', false);
99 + await run(ret, (r) => `Review ${id} ${(r as { status?: string })?.status ?? action}.`, () => adminApi.reviewAction(id, action));
100 +}
101 +
102 +/**
103 + * Conflict resolution: the payload only carries the two values and their source URLs, not claim ids. We look the
104 + * claim up in the entity's claim history (same property, same value, same source URL — newest first) and approve
105 + * with `resolution.keep_claim_id` so the API promotes it and supersedes the other.
106 + */
107 +export async function keepConflictSideAction(formData: FormData): Promise<void> {
108 + const id = String(formData.get('id') ?? '');
109 + const slug = String(formData.get('slug') ?? '');
110 + const property = String(formData.get('property') ?? '');
111 + const valueJson = String(formData.get('value') ?? 'null');
112 + const sourceUrl = String(formData.get('source_url') ?? '');
113 + const ret = String(formData.get('return') ?? '/admin/review');
114 + await run(
115 + ret,
116 + () => `Conflict ${id} resolved — kept ${property} = ${valueJson}.`,
117 + async () => {
118 + let value: unknown = null;
119 + try {
120 + value = JSON.parse(valueJson);
121 + } catch {
122 + value = valueJson;
123 + }
124 + const hist = await claimHistory(slug, property);
125 + const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
126 + const match = hist.items.find((c) => same(c.value, value) && (!sourceUrl || c.source_url === sourceUrl)) ?? hist.items.find((c) => same(c.value, value));
127 + if (!match) throw new Error(`No claim with ${property} = ${valueJson} found in the history of ${slug}; approve or reject without promotion instead.`);
128 + return adminApi.reviewAction(id, 'approve', { keep_claim_id: match.id, kept_value: value, kept_source: sourceUrl || null });
129 + },
130 + );
131 +}
132 +
133 +// ------------------------------------------------------------------------------------------------------------ duplicates
134 +export async function mergeAction(formData: FormData): Promise<void> {
135 + const source = String(formData.get('source_id') ?? '');
136 + const target = String(formData.get('target_id') ?? '');
137 + const ret = String(formData.get('return') ?? '/admin/entities/duplicates');
138 + await run(ret, () => `Merged ${source} → ${target}.`, () => adminApi.merge(source, target));
139 +}
140 +
141 +// ------------------------------------------------------------------------------------------------------------ maintenance
142 +export async function flushCacheAction(): Promise<void> {
143 + await run('/admin/cache', (r) => `Cache flushed (${(r as { flushed?: number })?.flushed ?? '?'} keys).`, () => adminApi.flushCache());
144 +}
145 +export async function recomputeStatsAction(): Promise<void> {
146 + await run('/admin/cache', () => 'Stats recompute triggered.', () => adminApi.recomputeStats());
147 +}
148 +export async function recomputeQualityAction(): Promise<void> {
149 + await run('/admin/cache', () => 'Quality recompute triggered.', () => adminApi.recomputeQuality());
150 +}
added apps/web/src/lib/admin/admin-api.ts +110 −0
@@ -0,0 +1,110 @@
1 +import 'server-only';
2 +import { redirect } from 'next/navigation';
3 +import { API_URL, ApiError, type Query } from '@/lib/api';
4 +import { getAdminToken } from './session';
5 +import type { AdminConnectorsPayload, AdminDocument, AdminError, AdminJobsPayload, AdminLlmHealth, AdminLlmJobsPayload, AdminOverview, AdminPage, AdminRun, AdminSnapshot, DuplicatesPayload, Infrastructure, ReviewPayload } from './types';
6 +
7 +/**
8 + * Server-only admin client. Every call carries `x-aia-admin-token` from the httpOnly cookie (or an explicit token
9 + * during login) and is `no-store`. A 401/403 raises `AdminAuthError`; pages turn it into a redirect to the login form.
10 + */
11 +const BASE = `${API_URL}/api/v1/admin`;
12 +
13 +export class AdminAuthError extends Error {
14 + constructor() {
15 + super('admin token missing or rejected');
16 + this.name = 'AdminAuthError';
17 + }
18 +}
19 +
20 +function qs(query?: Query): string {
21 + if (!query) return '';
22 + const p = new URLSearchParams();
23 + for (const [k, v] of Object.entries(query)) {
24 + if (v === undefined || v === null || v === '') continue;
25 + p.set(k, String(v));
26 + }
27 + const s = p.toString();
28 + return s ? `?${s}` : '';
29 +}
30 +
31 +export async function adminRequest<T>(path: string, opts: { method?: 'GET' | 'POST' | 'PATCH'; query?: Query; body?: unknown; token?: string } = {}): Promise<T> {
32 + const token = opts.token ?? (await getAdminToken());
33 + if (!token) throw new AdminAuthError();
34 + const url = `${BASE}${path}${qs(opts.query)}`;
35 + const init: RequestInit = { method: opts.method ?? 'GET', cache: 'no-store', headers: { accept: 'application/json', 'x-aia-admin-token': token } };
36 + if (opts.body !== undefined) {
37 + init.body = JSON.stringify(opts.body);
38 + (init.headers as Record<string, string>)['content-type'] = 'application/json';
39 + }
40 + let res: Response;
41 + try {
42 + res = await fetch(url, init);
43 + } catch (e) {
44 + throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);
45 + }
46 + if (res.status === 401 || res.status === 403) throw new AdminAuthError();
47 + if (!res.ok) {
48 + let detail: string | null = null;
49 + try {
50 + const body = (await res.json()) as { detail?: unknown };
51 + detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;
52 + } catch {
53 + /* non-JSON error body */
54 + }
55 + throw new ApiError(res.status, path, detail);
56 + }
57 + return (await res.json()) as T;
58 +}
59 +
60 +/** Ensure a session cookie exists; otherwise go to the login form. */
61 +export async function requireAdmin(): Promise<string> {
62 + const token = await getAdminToken();
63 + if (!token) redirect('/admin');
64 + return token;
65 +}
66 +
67 +export type Loaded<T> = { ok: true; data: T } | { ok: false; error: string };
68 +
69 +/** Resolve an admin fetch for a page panel: auth failure → login redirect; other failures → `{ ok: false, error }`. */
70 +export async function load<T>(p: Promise<T>): Promise<Loaded<T>> {
71 + try {
72 + return { ok: true, data: await p };
73 + } catch (e) {
74 + if (e instanceof AdminAuthError) redirect('/admin?expired=1');
75 + const msg = e instanceof ApiError ? (e.detail ? `${e.status}: ${e.detail}` : e.message) : (e as Error).message;
76 + return { ok: false, error: msg };
77 + }
78 +}
79 +
80 +export const adminApi = {
81 + overview: (token?: string) => adminRequest<AdminOverview>('/overview', { token }),
82 + connectors: () => adminRequest<AdminConnectorsPayload>('/connectors'),
83 + runConnector: (name: string, force = true) => adminRequest<{ queued: boolean }>(`/connectors/${encodeURIComponent(name)}/run`, { method: 'POST', body: { force } }),
84 + patchConnector: (name: string, patch: { enabled?: boolean; interval_seconds?: number; priority?: number }) => adminRequest<unknown>(`/connectors/${encodeURIComponent(name)}`, { method: 'PATCH', body: patch }),
85 + runs: (query: Query) => adminRequest<AdminPage<AdminRun>>('/runs', { query }),
86 + errors: (query: Query) => adminRequest<AdminPage<AdminError>>('/errors', { query }),
87 + documents: (query: Query) => adminRequest<AdminPage<AdminDocument>>('/documents', { query }),
88 + document: (id: string) => adminRequest<AdminDocument>(`/documents/${encodeURIComponent(id)}`),
89 + snapshot: (id: string) => adminRequest<AdminSnapshot>(`/snapshots/${encodeURIComponent(id)}`),
90 + jobs: (query: Query) => adminRequest<AdminJobsPayload>('/jobs', { query }),
91 + retryJob: (id: string) => adminRequest<unknown>(`/jobs/${encodeURIComponent(id)}/retry`, { method: 'POST', body: {} }),
92 + requeueDead: () => adminRequest<{ requeued?: number } & Record<string, unknown>>('/jobs/requeue-dead', { method: 'POST', body: {} }),
93 + llmJobs: (query: Query) => adminRequest<AdminLlmJobsPayload>('/llm-jobs', { query }),
94 + llmHealth: () => adminRequest<AdminLlmHealth>('/llm/health'),
95 + review: (query: Query) => adminRequest<ReviewPayload>('/review', { query }),
96 + reviewAction: (id: string, action: 'approve' | 'reject' | 'edit', resolution?: Record<string, unknown>) => adminRequest<{ ok: boolean; status: string; effect?: unknown }>(`/review/${encodeURIComponent(id)}`, { method: 'POST', body: { action, resolution } }),
97 + duplicates: (query: Query) => adminRequest<DuplicatesPayload>('/entities/duplicates', { query }),
98 + merge: (source_id: string, target_id: string) => adminRequest<unknown>('/entities/merge', { method: 'POST', body: { source_id, target_id } }),
99 + infrastructure: () => adminRequest<Infrastructure>('/infrastructure'),
100 + flushCache: () => adminRequest<{ flushed: number }>('/cache/flush', { method: 'POST', body: {} }),
101 + recomputeStats: () => adminRequest<unknown>('/stats/recompute', { method: 'POST', body: {} }),
102 + recomputeQuality: () => adminRequest<unknown>('/quality/recompute', { method: 'POST', body: {} }),
103 +};
104 +
105 +/** Public (non-admin) history lookup used to map a conflict payload to concrete claim ids. */
106 +export async function claimHistory(slug: string, property: string): Promise<{ items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] }> {
107 + const res = await fetch(`${API_URL}/api/v1/entities/${encodeURIComponent(slug)}/history?property=${encodeURIComponent(property)}`, { cache: 'no-store', headers: { accept: 'application/json' } });
108 + if (!res.ok) throw new ApiError(res.status, '/history', null);
109 + return (await res.json()) as { items: { id: string; property: string; value: unknown; source_url: string | null; status: string; observed_at: string }[] };
110 +}
added apps/web/src/lib/admin/session.ts +31 −0
@@ -0,0 +1,31 @@
1 +import 'server-only';
2 +import { cookies } from 'next/headers';
3 +
4 +/**
5 + * Admin session = the admin token itself, stored in an httpOnly cookie set by the login server action.
6 + * It is read only on the server (`cookies()`), forwarded as `x-aia-admin-token`, and never rendered.
7 + */
8 +export const ADMIN_COOKIE = 'aia-admin';
9 +export const ADMIN_COOKIE_MAX_AGE = 12 * 3600; // 12 h
10 +
11 +export async function getAdminToken(): Promise<string | null> {
12 + const store = await cookies();
13 + const v = store.get(ADMIN_COOKIE)?.value;
14 + return v && v.length > 0 ? v : null;
15 +}
16 +
17 +export async function setAdminToken(token: string): Promise<void> {
18 + const store = await cookies();
19 + store.set(ADMIN_COOKIE, token, {
20 + httpOnly: true,
21 + sameSite: 'lax',
22 + secure: process.env.NODE_ENV === 'production',
23 + path: '/admin',
24 + maxAge: ADMIN_COOKIE_MAX_AGE,
25 + });
26 +}
27 +
28 +export async function clearAdminToken(): Promise<void> {
29 + const store = await cookies();
30 + store.set(ADMIN_COOKIE, '', { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path: '/admin', maxAge: 0 });
31 +}
added apps/web/src/lib/admin/types.ts +260 −0
@@ -0,0 +1,260 @@
1 +/** Admin API shapes (docs/API.md "Admin routes" + live observations 2026-09-11). Loose on purpose: we render what we know. */
2 +import type { Num, Stats } from '@/lib/types';
3 +
4 +export interface AdminRun {
5 + id: string;
6 + connector_name: string;
7 + started_at: string | null;
8 + finished_at: string | null;
9 + status: string;
10 + duration_ms: Num;
11 + docs_discovered: Num;
12 + docs_fetched: Num;
13 + docs_changed: Num;
14 + docs_unchanged: Num;
15 + docs_failed: Num;
16 + entities_created: Num;
17 + entities_updated: Num;
18 + claims_written: Num;
19 + relations_written: Num;
20 + events_emitted: Num;
21 + error: string | null;
22 + meta: Record<string, unknown>;
23 +}
24 +
25 +export interface AdminOverview {
26 + stats: Partial<Stats> & Record<string, unknown>;
27 + queue: Record<string, Record<string, Num>>;
28 + heartbeats: Record<string, unknown>;
29 + connectors: Record<string, Num>;
30 + review_pending: Num;
31 + review_by_kind?: Record<string, Num>;
32 + recent_errors: Num;
33 + llm: { available?: boolean; jobs_24h: Num; tokens_24h: Num; failed_24h?: Num; by_stage: { stage: string; status: string; n: Num }[] };
34 + recent_runs?: AdminRun[];
35 + archive?: Record<string, Num>;
36 +}
37 +
38 +export interface AdminConnector {
39 + name: string;
40 + source_id: string | null;
41 + label: string;
42 + description: string | null;
43 + enabled: boolean;
44 + priority: Num;
45 + interval_seconds: Num;
46 + min_interval_seconds?: Num;
47 + max_interval_seconds?: Num;
48 + parser_version: string | null;
49 + rate_limit_per_min?: Num;
50 + owner?: string;
51 + last_attempt_at: string | null;
52 + last_success_at: string | null;
53 + last_change_at: string | null;
54 + next_run_at: string | null;
55 + last_duration_ms: Num;
56 + consecutive_failures: Num;
57 + consecutive_unchanged?: Num;
58 + circuit_open_until: string | null;
59 + health: string;
60 + meta: Record<string, unknown>;
61 + source_key?: string;
62 + source_name?: string | null;
63 + source_tier?: Num;
64 + source_domain?: string | null;
65 + last_run?: AdminRun | null;
66 + documents?: Num;
67 + snapshots?: Num;
68 + run_now_pending?: boolean;
69 + in_code?: boolean;
70 +}
71 +export interface AdminConnectorsPayload {
72 + items: AdminConnector[];
73 + unregistered_in_db?: string[];
74 +}
75 +
76 +export interface AdminPage<T> {
77 + items: T[];
78 + total: number;
79 + limit: number;
80 + offset: number;
81 +}
82 +
83 +export interface AdminError {
84 + id: number | string;
85 + connector_name: string | null;
86 + run_id: string | null;
87 + url: string | null;
88 + error_type: string | null;
89 + message: string;
90 + created_at: string;
91 +}
92 +
93 +export interface AdminDocument {
94 + id: string;
95 + source_id: string | null;
96 + connector_name: string | null;
97 + url: string;
98 + canonical_url: string | null;
99 + doc_type: string | null;
100 + title: string | null;
101 + entity_id: string | null;
102 + status: string;
103 + first_seen_at: string | null;
104 + last_fetched_at: string | null;
105 + last_changed_at: string | null;
106 + last_status: Num;
107 + etag?: string | null;
108 + last_modified?: string | null;
109 + content_hash?: string | null;
110 + fetch_count: Num;
111 + change_count: Num;
112 + fail_count: Num;
113 + fetch_interval_s?: Num;
114 + next_fetch_at?: string | null;
115 + priority?: Num;
116 + needs_llm?: boolean;
117 + meta?: Record<string, unknown>;
118 + entity_slug?: string | null;
119 + entity_name?: string | null;
120 + entity_type?: string | null;
121 + snapshots?: number | AdminSnapshotRow[];
122 + last_processing_status?: string | null;
123 + source_name?: string | null;
124 + source_tier?: Num;
125 +}
126 +
127 +export interface AdminSnapshotRow {
128 + id: string;
129 + run_id: string | null;
130 + url: string;
131 + final_url: string | null;
132 + observed_at: string;
133 + http_status: Num;
134 + content_type: string | null;
135 + content_hash: string | null;
136 + byte_size: Num;
137 + text_hash: string | null;
138 + parser_version: string | null;
139 + connector_version: string | null;
140 + transport: string | null;
141 + changed: boolean;
142 + processing_status: string | null;
143 + created_at: string;
144 + has_structured?: boolean;
145 + has_text?: boolean;
146 + has_raw?: boolean;
147 +}
148 +
149 +export interface AdminSnapshot extends AdminSnapshotRow {
150 + document_id: string;
151 + headers?: Record<string, string>;
152 + structured: unknown;
153 + diff: unknown;
154 + document_url?: string;
155 + doc_type?: string | null;
156 + connector_name?: string | null;
157 + entity_id?: string | null;
158 + entity_slug?: string | null;
159 + entity_name?: string | null;
160 + text: string | null;
161 + text_chars?: Num;
162 + text_truncated?: boolean;
163 + text_error?: string | null;
164 +}
165 +
166 +export interface AdminJob {
167 + id: string;
168 + kind: string;
169 + payload: Record<string, unknown>;
170 + priority: Num;
171 + status: string;
172 + attempts: Num;
173 + max_attempts: Num;
174 + run_after: string | null;
175 + locked_by: string | null;
176 + locked_at: string | null;
177 + started_at: string | null;
178 + finished_at: string | null;
179 + error: string | null;
180 + batch_id: string | null;
181 + dedupe_key: string | null;
182 + created_at: string;
183 +}
184 +export type AdminJobsPayload = AdminPage<AdminJob> & { depth?: Record<string, Record<string, Num>> };
185 +
186 +export interface AdminLlmJob {
187 + id: string;
188 + job_id: string | null;
189 + task_type: string | null;
190 + stage: string | null;
191 + engine: string | null;
192 + model: string | null;
193 + node: string | null;
194 + schema_name: string | null;
195 + snapshot_id: string | null;
196 + entity_id: string | null;
197 + input_tokens: Num;
198 + output_tokens: Num;
199 + duration_ms: Num;
200 + status: string;
201 + error: string | null;
202 + created_at: string;
203 +}
204 +export type AdminLlmJobsPayload = AdminPage<AdminLlmJob> & { totals?: { stage: string; model: string; status: string; n: Num; input_tokens: Num; output_tokens: Num; avg_ms: Num }[] };
205 +export interface AdminLlmHealth {
206 + engine?: string;
207 + available: boolean;
208 + reachable?: boolean;
209 + models?: Record<string, string>;
210 + embedding_model?: string;
211 + base_url_configured?: boolean;
212 + error?: string;
213 +}
214 +
215 +export interface ReviewEntity {
216 + id: string;
217 + name: string;
218 + slug: string;
219 + status: string;
220 + entity_type: string;
221 +}
222 +export interface ReviewItem {
223 + id: string;
224 + kind: string;
225 + entity_ids: string[];
226 + payload: Record<string, unknown>;
227 + reason: string | null;
228 + status: string;
229 + resolution: Record<string, unknown> | null;
230 + created_at: string;
231 + resolved_at: string | null;
232 + dedupe_key?: string | null;
233 + entities: ReviewEntity[] | null;
234 +}
235 +export type ReviewPayload = AdminPage<ReviewItem> & { by_kind?: { kind: string; status: string; n: Num }[] };
236 +
237 +export interface DuplicateSide {
238 + id: string;
239 + slug: string;
240 + name: string;
241 + organization: string | null;
242 + first_seen_at: string | null;
243 + claims: Num;
244 +}
245 +export interface DuplicatesPayload {
246 + threshold: Num;
247 + items: { entity_type: string; similarity: Num; a: DuplicateSide; b: DuplicateSide }[];
248 +}
249 +
250 +export interface Infrastructure {
251 + hostname?: string;
252 + python?: string;
253 + platform?: string;
254 + api_uptime_s?: Num;
255 + env?: string;
256 + heartbeats?: Record<string, unknown>;
257 + archive?: { raw_bytes: Num; raw_files: Num; text_bytes: Num; text_files: Num };
258 + data_dir_exists?: boolean;
259 + database?: { db_bytes: Num; database?: string; pg_version?: string; connections?: Num; max_connections?: Num; tables?: { table: string; total_bytes: Num; data_bytes: Num; rows_estimate: Num }[] };
260 +}
modified apps/web/src/lib/site.ts +39 −1
@@ -158,8 +158,43 @@ export const routes = {
158 158 tools: () => '/tools',
159 159 changes: () => '/changes',
160 160 changesDay: (date: string) => `/changes/${date}`,
161 timeline: () => '/timeline',
161 + timeline: (q?: { entity?: string; year?: string; category?: string }) => {
162 + const p = new URLSearchParams();
163 + if (q?.entity) p.set('entity', q.entity);
164 + if (q?.year) p.set('year', q.year);
165 + if (q?.category) p.set('category', q.category);
166 + const s = p.toString();
167 + return s ? `/timeline?${s}` : '/timeline';
168 + },
162 169 compare: (ids?: string[]) => (ids?.length ? `/compare?ids=${ids.map(encodeURIComponent).join(',')}` : '/compare'),
170 + prices: () => '/prices',
171 + hardwareFit: (q?: { memory_gb?: number | string; quant?: string; context?: number | string }) => {
172 + const p = new URLSearchParams();
173 + if (q?.memory_gb !== undefined && q.memory_gb !== '') p.set('memory_gb', String(q.memory_gb));
174 + if (q?.quant) p.set('quant', q.quant);
175 + if (q?.context !== undefined && q.context !== '') p.set('context', String(q.context));
176 + const s = p.toString();
177 + return s ? `/hardware/fit?${s}` : '/hardware/fit';
178 + },
179 + benchmark: (slug: string, q?: { config?: string; model?: string }) => {
180 + const p = new URLSearchParams();
181 + if (q?.config) p.set('config', q.config);
182 + if (q?.model) p.set('model', q.model);
183 + const s = p.toString();
184 + return `/benchmarks/${encodeURIComponent(slug)}${s ? `?${s}` : ''}`;
185 + },
186 + diff: (q?: { a?: string; b?: string; scope?: string }) => {
187 + const p = new URLSearchParams();
188 + if (q?.a) p.set('a', q.a);
189 + if (q?.b) p.set('b', q.b);
190 + if (q?.scope && q.scope !== 'all') p.set('scope', q.scope);
191 + const s = p.toString();
192 + return s ? `/diff?${s}` : '/diff';
193 + },
194 + graph: (slug: string, depth?: 1 | 2) => `/graph/${encodeURIComponent(slug)}${depth === 2 ? '?depth=2' : ''}`,
195 + /** Entity page "as of" view (History tab). */
196 + entityAsOf: (e: { entity_type: string; slug: string }, date: string) => `${routes.entity(e)}?tab=history&asof=${encodeURIComponent(date)}`,
197 + admin: (section?: string) => (section ? `/admin/${section}` : '/admin'),
163 198 explore: () => '/explore',
164 199 exploreType: (t: string) => `/explore/${encodeURIComponent(t)}`,
165 200 methodology: () => '/methodology',
@@ -192,6 +227,7 @@ export const exploreNav = [
192 227 export const primaryNav = [
193 228 { href: '/changes', label: 'Changes' },
194 229 { href: '/timeline', label: 'Timeline' },
230 + { href: '/prices', label: 'Prices' },
195 231 { href: '/compare', label: 'Compare' },
196 232 { href: '/developers', label: 'Developers' },
197 233 ];
@@ -199,6 +235,8 @@ export const moreNav = [
199 235 { href: '/about', label: 'About' },
200 236 { href: '/methodology', label: 'Methodology' },
201 237 { href: '/sources', label: 'Sources' },
238 + { href: '/diff', label: 'Diff two dates' },
239 + { href: '/hardware/fit', label: 'Hardware fit' },
202 240 { href: '/developers', label: 'API & Developers' },
203 241 { href: '/bot', label: 'AIAtlasBot' },
204 242 ];
modified apps/web/src/lib/types.ts +48 −5
@@ -265,15 +265,37 @@ export type CompaniesPage = Page<CompanyRow> & { facets?: CompanyFacets };
265 265 export type ProviderRow = EntitySummary & { model_count: Num; price_count: Num; min_input_per_mtok: Num; min_output_per_mtok: Num };
266 266 export type BenchmarkRow = EntitySummary & { result_count: Num; model_count: Num; top: { model: EntitySummary; score: number } | null };
267 267
268 +export interface PriceIndexPoint {
269 + day: string;
270 + median_input: Num;
271 + median_output: Num;
272 + min_input: Num;
273 + max_input?: Num;
274 + models: number;
275 + offers?: number;
276 +}
268 277 export interface PriceIndex {
269 series: { day: string; median_input: Num; median_output: Num; min_input: Num; models: number }[];
278 + days?: number;
279 + note?: string;
280 + series: PriceIndexPoint[];
270 281 movers: ChangeEvent[];
271 282 }
272 283
284 +export interface HardwareFitItem {
285 + model: EntitySummary;
286 + parameter_count: number;
287 + estimated_memory_gb: number;
288 + fits: boolean;
289 + headroom_gb: number;
290 + quantization: string;
291 + note: string;
292 +}
273 293 export interface HardwareFit {
274 294 inputs: Record<string, unknown>;
295 + estimated?: boolean;
296 + counts?: { fits?: Num; evaluated?: Num };
275 297 assumptions: string[];
276 items: { model: EntitySummary; parameter_count: number; estimated_memory_gb: number; fits: boolean; headroom_gb: number; quantization: string; note: string }[];
298 + items: HardwareFitItem[];
277 299 }
278 300
279 301 export interface ExploreType {
@@ -294,7 +316,8 @@ export interface ChangeCategories {
294 316 }
295 317
296 318 export interface TimelinePayload {
297 items: { month: string; events: ChangeEvent[] }[];
319 + items: { month: string; count?: number; events: ChangeEvent[] }[];
320 + total?: number;
298 321 }
299 322
300 323 export interface CompareDimension {
@@ -302,6 +325,8 @@ export interface CompareDimension {
302 325 label: string;
303 326 unit?: string;
304 327 kind: 'number' | 'text' | 'list' | 'bool' | 'date';
328 + /** Where the value comes from: `attr` (claims) · `prices` · `results` (benchmark) — informational. */
329 + source?: string;
305 330 }
306 331 export interface ComparePayload {
307 332 entity_type: string;
@@ -312,6 +337,7 @@ export interface ComparePayload {
312 337 export interface DiffPayload {
313 338 a: string;
314 339 b: string;
340 + scope?: Record<string, unknown> | string;
315 341 new_entities: EntitySummary[];
316 342 gone_entities: EntitySummary[];
317 343 property_changes: ChangeEvent[];
@@ -366,12 +392,29 @@ export interface SitemapPayload {
366 392 total: number;
367 393 }
368 394
395 +export interface GraphNode {
396 + id: string;
397 + slug: string;
398 + name: string;
399 + entity_type: string;
400 + organization_name?: string | null;
401 +}
402 +export interface GraphEdge {
403 + source: string;
404 + target: string;
405 + predicate: string;
406 +}
369 407 export interface GraphPayload {
370 nodes: { id: string; slug: string; name: string; entity_type: string; organization_name?: string }[];
371 edges: { source: string; target: string; predicate: string }[];
408 + root?: string;
409 + nodes: GraphNode[];
410 + edges: GraphEdge[];
372 411 }
373 412
374 413 export interface AsOfPayload {
414 + id?: string;
415 + slug?: string;
416 + name?: string;
417 + entity_type?: string;
375 418 existed: boolean;
376 419 first_seen_at: string | null;
377 420 date: string;
modified docs/FRONTEND.md +88 −22
@@ -102,33 +102,99 @@ Do not fork these; extend with props or add new components in your own folder.
102 102
103 103 ## 5. Routes built
104 104
105 `/` · `/search` · `/models` (+ facets, sort) · `/models/[slug]` · `/companies` · `/companies/[slug]` · `/[type]/[slug]` (providers, benchmarks,
106 hardware, papers, frameworks, datasets, tools, repositories) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` (types without a path)
107 · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` listings · `/changes` · `/changes/[date]` · `/timeline` (basic)
108 · `/compare?ids=` (basic table) · `/methodology` · `/sources` · `/about` · `/developers` · `/bot` · `robots.ts` · `/sitemap.xml` + `/sitemap/[shard].xml`
109 · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.
110
111 ## 6. Left for the next agent (use the components above)
112
113 - **Compare** picker UI (suggest-driven multi-select, pinned "compare tray", shareable URL), per-type dimension rendering, shared-benchmark rows.
114 - **Timeline** — richer `/timeline` (year scrubber, category chips, entity autocomplete, month density bars via `Bars`).
115 - **Hardware fit tool** `/hardware/fit` (form memory_gb/quant/context → `GET /hardware/fit`, table with `Estimated`, assumptions list).
116 - **Price index** `/prices` (`GET /prices/index``LineChart` median/min, movers via `ChangeRow`) and per-model price sparkline in `/models` rows.
117 - **Benchmarks** — leaderboard page per benchmark with config filter (`?config=`), `history=1`, result history chart per model.
118 - **Diff / history mode**`/diff?a=&b=&scope=` and an "as of" toggle on entity pages (`GET /entities/{slug}/asof`, `/history?property=`) showing superseded claims.
119 - **Entity graph** (`GET /entities/{slug}/graph`) as an SVG neighbourhood on the Overview aside.
120 - **Admin** (`/admin`, token in a cookie, `x-aia-admin-token` from a server action; never expose it client-side): connectors, runs, errors, review queue, duplicates, LLM jobs, infrastructure.
121 - Listings for papers/frameworks/datasets/tools could gain type-specific tables (currently `EntityRow` lists via `GenericListing`).
122 - `manifest.ts`, per-entity `opengraph-image` (optional).
105 +`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`)
106 +· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows
107 +`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables)
108 +· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix)
109 +· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources`
110 +· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts`
111 +· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.
112 +
113 +## 6. Wave 2 (2026-09-11) — components and page patterns added
114 +
115 +All numbers still come from the API; nothing below hardcodes counts, slugs or dates. Ownership of shared files stays as in §4 — extend, don't fork.
116 +
117 +### Compare (`components/compare/`)
118 +- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab
119 + `aia-compare-change` event + cross-tab `storage`. `useCompareTray()``{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`;
120 + helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`),
121 + `compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type).
122 +- `CompareButton({ e, size: 'sm' | 'md' })` (client) — toggle with `aria-pressed`; used in entity headers, `/models`, `/providers`, `/hardware`, `/frameworks`,
123 + leaderboards and hardware-fit rows. `CompareTrayBar` (client) — fixed bar above the mobile tab bar / bottom-right on desktop, hidden when empty and on `/compare`;
124 + mount it **once per page** that shows Compare buttons.
125 +- `ComparePicker` + `CompareTray` (client, `compare-picker.tsx`) — `/search/suggest` autocomplete filtered to the tray's type, chips with remove/clear, "Compare n →";
126 + seeds the tray from `?ids=` (URL wins) and mirrors tray changes back with `router.replace`.
127 +- `CompareMatrix`, `SharedBenchmarks`, `ComparePrices` (server, `compare-matrix.tsx`) — dimension × entity table (sticky first column inside `.table-scroll`,
128 + `kind`-aware cells via `lib/format`, best-per-row bold — lower is better for `*_per_mtok`, per-cell `T{n} · source · ago` provenance), benchmarks present for
129 + every model, cheapest input/output per provider × entity. Gotcha: `.table-scroll .data-table td { white-space: nowrap }` out-specifies Tailwind
130 + `whitespace-normal` — use an inline style for a wrapping sticky column.
131 +
132 +### Timeline & prices
133 +- `components/timeline/chip-row.tsx``ChipRow` (server link chips: years, categories, windows). Years derive from `stats.first_entity_at` and the months returned.
134 +- `/timeline``Bars` month-density strip, sticky month headings, `ChangeRow showDate live={false}`, dynamic metadata (entity views are `noindex`).
135 +- `components/prices/``ScaleToggle` (client: linear/log, `aria-pressed`, mirrors `?scale=log` with `replaceState`; the page renders both charts server-side),
136 + `PriceMovers` (PRICE_CHANGED events as a table; their `old_value/new_value` are objects, so `Delta` would JSON-dump them — format them yourself).
137 +- `LineChart` gained `yScale?: 'linear' | 'log'` (d3 `scaleLog`, points ≤ 0 dropped). Existing calls are unchanged.
138 +- `/prices?model=` and `provider=` take **slugs** (API 404s on free text) — label fields accordingly.
139 +
140 +### Benchmarks & hardware
141 +- `components/benchmarks/leaderboard.tsx``Leaderboard` (rank, model, score + relative bar in `var(--type-benchmark)`, config summary, evaluated, source,
142 + "History" link, `CompareButton`, `Pagination`), `ConfigChips` + `configChips(rows)` (`?config=`), `HistoryChart` (per-model `LineChart`, honest when < 2 points).
143 + API note: `results?config=` is a **value substring match** (`config=v2.1`), not `key=value` or JSON. `evaluated_at` is often null (falls back to observed date).
144 +- `/benchmarks/[slug]` tabs: Leaderboard · Definition (`KeyValue` with provenance) · Relations · History (`HistoryPanel`) · Timeline · Sources.
145 +- `components/hardware/fit-form.tsx``FitForm` (GET form: memory preset `<select>` with Apple/NVIDIA optgroups + free numeric field, `quant`, `context`;
146 + exports `APPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS`). `/hardware/fit` shows `Estimated` prominently, the API `assumptions`, `counts.fits/evaluated`,
147 + `?fits=1` (filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled with `memory_gb`.
148 +
149 +### Typed listings (`components/listing/typed-listing.tsx`)
150 +- `TypedListing({ title, eyebrow, lede, basePath, searchParams, fetch, columns: Column[], sorts, filters, emptyTitle, emptyHint, note, compare, headerAside })` — column-driven paged table (FilterBar + Pagination +
151 + Unavailable/EmptyState) used by `/papers /frameworks /datasets /tools`; helpers `Dash str list`. `/explore/{type}` supports `sort=updated|name|quality|first_seen|release|stars`.
152 + Keep `GenericListing` for row-style lists; it now accepts `rowTrailing(e)` and `headerAside`.
153 +
154 +### History mode, diff, graph
155 +- `EntityPage` is now `async` and takes `asof?` and `historyProperty?` (pages pass `searchParams.asof/property`). Header actions row: `CompareButton`,
156 + "Explore graph" → `routes.graph(slug)`, hardware-only "What can this run?". New **History** tab (before Timeline) → `components/entity/history.tsx`:
157 + `HistoryPanel({ d, asof, asofPayload, claims, property })` = `AsOfPicker` (client date input, `router.replace` to `?tab=history&asof=`) + `AsOfBlock`
158 + (banner "Viewing AI Atlas as of …" on `bg-accent-soft`, `existed: false` state, attributes as known then via `KeyValue`) + `ClaimHistory` (claims grouped by
159 + property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip — `conflicting` rows in danger with `border-l-2`;
160 + `?property=` filters via `api.entityHistory(slug, property)`). Fetch history always for the tab; as-of only when `?asof`.
161 +- `/diff` — GET form (from/to dates, scope select `all | models | org:<slug>` from `/companies?sort=models`, custom `family:<name>`), `StatGrid` from `counts`,
162 + sections New / Gone (`EntityRow`) and Property / Price / Benchmark changes (`ChangeRow`); API lists are capped at 200 — say "first N of total". a ≥ b → API 400 → honest message.
163 +- `/graph/[slug]` + `components/graph/graph-explorer.tsx` (client) — d3-force layout run after mount (server renders the frame only → stable SSR), nodes coloured
164 + `var(--type-*)`, root pinned, edge labels always ≤ 40 edges else on hover, hover/focus highlights the neighbourhood, click/Enter → `routes.entity`, depth 1/2 links,
165 + cap 80 nodes + note, type and predicate legends, accessible text fallback list. `routes.graph(slug, depth)`.
166 +
167 +### Admin (`/admin`, `lib/admin/`, `components/admin/`)
168 +- Cookie `aia-admin` (httpOnly, sameSite=lax, secure in prod, `path=/admin`, 12 h) set by the `loginAction` server action after validating the token against
169 + `GET /admin/overview`. `lib/admin/admin-api.ts` (`server-only`): `adminRequest` adds `x-aia-admin-token` from the cookie, `no-store`; 401/403 → `AdminAuthError`
170 +`redirect('/admin?expired=1')`; `requireAdmin()`; typed `adminApi.*` for every admin route. **The token never reaches the client** (grep the HTML for it in QA).
171 +- `lib/admin/actions.ts` (`'use server'`): login/logout, run/toggle connector, retry job, requeue dead, review approve/reject/keep-side, merge, flush cache,
172 + recompute stats/quality — each revalidates and redirects back with `?notice=&level=` (works without JS).
173 +- `components/admin/`: `shell.tsx` (header + sticky left nav ≥ lg), `nav-select.tsx` (mobile select), `login-form.tsx` (`useActionState`), `ui.tsx`
174 + (`StatusChip KindChip Bool Notice JsonPre ActionButton AdminTitle AdminFilters Mono Trunc`). Pages: overview, connectors, runs, errors, documents(+[id]),
175 + snapshots/[id], jobs, llm-jobs, review, entities/duplicates, infrastructure, cache — all `force-dynamic`, `robots: noindex`, `DataTable compact` (+ `scroll`).
176 +- Conflict review items carry no claim ids; "Keep this" resolves the claim through `/entities/{slug}/history?property=` before approving with `resolution.keep_claim_id`.
177 +
178 +### Polish
179 +- `manifest.ts` (standalone, shortcuts, svg + 192/512 PNG icons from `icon-192.png/route.tsx` + `icon-512.png/route.tsx`), `components/brand/og.tsx`
180 + (`Frame Eyebrow Title Facts Fallback` — shared OG chrome; hex constants are allowed there like in the other ImageResponse files), per-type `opengraph-image.tsx`
181 + for models and companies (3 key facts, brand-only fallback when the slug is unknown — returns 200), `not-found.tsx` with a search form, `/developers` with live trimmed `/stats`
182 + and one model response plus Compare/Diff/history/asof/graph/fit/price-index rows and response conventions.
183 +
184 +### Left for the next agent
185 +- Graph: no pan/zoom; edge labels can overlap on near-collinear edges. Timeline: `total` from the API equals the returned count, so "N of M" is impossible.
186 +- `/prices/index` needs ≥ 2 daily snapshots before the chart draws (the DB is one day old); movers and the offers table already work.
187 +- Per-model price sparkline in `/models` rows (`PriceSpark` exists) and pan/zoom on the graph remain optional.
123 188
124 189 ## 7. Verification before you report
125 190
126 191 1. `cd apps/web && pnpm typecheck` — zero errors. `pnpm build` must pass (Turbopack works; fall back to `next build --webpack` only if needed and say so).
127 2. Dev server on :8330 (`pnpm dev`). API on :8331 (`.venv/bin/aia api`). Run `node qa/screens.mjs` (add your routes to `PAGES`):
128 it checks HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth` at 390 and 1440 px, dark **and** light,
129 and that homepage counters equal `GET /api/v1/stats`. Screenshots land in `apps/web/qa/screens/`.
192 +2. Dev server on :8330 (`pnpm dev`). API on :8331 (`.venv/bin/aia api`). Run `node qa/screens.mjs` (wave-1 routes; add yours to `PAGES`) **and**
193 + `node qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN]` (wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now,
194 + diff with two dates, graph nodes; slugs discovered live). Both check HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth`
195 + at 390 and 1440 px, dark **and** light; screens.mjs also checks homepage counters against `GET /api/v1/stats`. Screenshots: `qa/screens/` and `qa/screens/wave2/`.
130 196 3. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.
131 4. `curl -sI localhost:8330/<type>/does-not-exist` → 404.
197 +4. `curl -sI localhost:8330/<type>/does-not-exist` → 404. `curl -s localhost:8330/admin | grep -c <token>` → 0.
132 198
133 199 ## 8. API notes observed live (2026-09-11) — keep in mind
134 200
modified pnpm-lock.yaml +39 −0
@@ -13,6 +13,9 @@ importers:
13 13 d3-array:
14 14 specifier: ^3.2.4
15 15 version: 3.2.4
16 + d3-force:
17 + specifier: ^3.0.0
18 + version: 3.0.0
16 19 d3-scale:
17 20 specifier: ^4.0.2
18 21 version: 4.0.2
@@ -44,6 +47,9 @@ importers:
44 47 '@types/d3-array':
45 48 specifier: ^3.2.1
46 49 version: 3.2.2
50 + '@types/d3-force':
51 + specifier: ^3.0.10
52 + version: 3.0.10
47 53 '@types/d3-scale':
48 54 specifier: ^4.0.9
49 55 version: 4.0.9
@@ -406,6 +412,9 @@ packages:
406 412 '@types/d3-array@3.2.2':
407 413 resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
408 414
415 + '@types/d3-force@3.0.10':
416 + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
417 +
409 418 '@types/d3-path@3.1.1':
410 419 resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
411 420
@@ -451,6 +460,14 @@ packages:
451 460 resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
452 461 engines: {node: '>=12'}
453 462
463 + d3-dispatch@3.0.1:
464 + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
465 + engines: {node: '>=12'}
466 +
467 + d3-force@3.0.0:
468 + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
469 + engines: {node: '>=12'}
470 +
454 471 d3-format@3.1.2:
455 472 resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
456 473 engines: {node: '>=12'}
@@ -463,6 +480,10 @@ packages:
463 480 resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
464 481 engines: {node: '>=12'}
465 482
483 + d3-quadtree@3.0.1:
484 + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
485 + engines: {node: '>=12'}
486 +
466 487 d3-scale@4.0.2:
467 488 resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
468 489 engines: {node: '>=12'}
@@ -479,6 +500,10 @@ packages:
479 500 resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
480 501 engines: {node: '>=12'}
481 502
503 + d3-timer@3.0.1:
504 + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
505 + engines: {node: '>=12'}
506 +
482 507 detect-libc@2.1.2:
483 508 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
484 509 engines: {node: '>=8'}
@@ -922,6 +947,8 @@ snapshots:
922 947
923 948 '@types/d3-array@3.2.2': {}
924 949
950 + '@types/d3-force@3.0.10': {}
951 +
925 952 '@types/d3-path@3.1.1': {}
926 953
927 954 '@types/d3-scale@4.0.9':
@@ -960,6 +987,14 @@ snapshots:
960 987
961 988 d3-color@3.1.0: {}
962 989
990 + d3-dispatch@3.0.1: {}
991 +
992 + d3-force@3.0.0:
993 + dependencies:
994 + d3-dispatch: 3.0.1
995 + d3-quadtree: 3.0.1
996 + d3-timer: 3.0.1
997 +
963 998 d3-format@3.1.2: {}
964 999
965 1000 d3-interpolate@3.0.1:
@@ -968,6 +1003,8 @@ snapshots:
968 1003
969 1004 d3-path@3.1.0: {}
970 1005
1006 + d3-quadtree@3.0.1: {}
1007 +
971 1008 d3-scale@4.0.2:
972 1009 dependencies:
973 1010 d3-array: 3.2.4
@@ -988,6 +1025,8 @@ snapshots:
988 1025 dependencies:
989 1026 d3-array: 3.2.4
990 1027
1028 + d3-timer@3.0.1: {}
1029 +
991 1030 detect-libc@2.1.2: {}
992 1031
993 1032 enhanced-resolve@5.24.5:
994 1033