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