SPB Git

Web app: home + 3D globe, satellites/launches, entities, stats/events, sources/admin/SEO; API fixes

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

116 changed files +9,213 −55

116 changed files
added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added apps/web/qa/entities.mjs +37 −0
@@ -0,0 +1,37 @@
1 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
2 +// The satelliteindex dev server port is read from .next/dev/lock (8310 may be taken by another project on this machine).
3 +import { readFileSync } from 'node:fs';
4 +let port = process.env.PORT ?? '8310';
5 +try { port = String(JSON.parse(readFileSync(new URL('../.next/dev/lock', import.meta.url), 'utf8')).port ?? port); } catch { /* fall back */ }
6 +const BASE = `http://localhost:${port}`;
7 +console.log(`QA against ${BASE}`);
8 +const PAGES = ['/constellations', '/constellations?sort=activity&service=navigation', '/constellation/starlink', '/constellation/gps', '/operators', '/operators?kind=agency&q=nasa', '/operator/spacex', '/operator/nasa', '/countries', '/countries?sort=debris', '/country/canada', '/country/china', '/constellation/does-not-exist'];
9 +const OUT = '/Users/simon-pierreboucher/Desktop/Projets/apps-web/satelliteindex/apps/web/qa/screens/entities';
10 +const browser = await chromium.launch();
11 +let failures = 0;
12 +for (const width of [390, 1440]) {
13 + const ctx = await browser.newContext({ viewport: { width, height: width === 390 ? 844 : 900 }, deviceScaleFactor: 1 });
14 + for (const p of PAGES) {
15 + const page = await ctx.newPage();
16 + const errors = [];
17 + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
18 + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
19 + const t0 = Date.now();
20 + const res = await page.goto(BASE + p, { waitUntil: 'networkidle', timeout: 120000 });
21 + const status = res?.status();
22 + const { sw, iw, h1 } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, iw: window.innerWidth, h1: document.querySelector('h1')?.textContent?.trim() }));
23 + const name = p.replace(/^\//, '').replace(/[\/?=&]+/g, '_') || 'root';
24 + await page.screenshot({ path: `${OUT}/${name}-${width}.png`, fullPage: true });
25 + const overflow = sw > iw;
26 + const expect404 = p.includes('does-not-exist');
27 + // A 404 document legitimately logs "Failed to load resource … 404" — that is the expected status, not a page error.
28 + const realErrors = expect404 ? errors.filter((e) => !/status of 404/.test(e)) : errors;
29 + const bad = overflow || realErrors.length || (!expect404 && status !== 200) || (expect404 && status !== 404);
30 + if (bad) failures++;
31 + console.log(`${bad ? 'FAIL' : 'ok '} ${width}px ${p} status=${status} scrollWidth=${sw}/${iw} ${Date.now() - t0}ms h1="${h1}"${errors.length ? '\n console: ' + errors.slice(0, 3).join(' | ') : ''}`);
32 + await page.close();
33 + }
34 + await ctx.close();
35 +}
36 +await browser.close();
37 +process.exit(failures ? 1 : 0);
added apps/web/qa/meta-screens.mjs +82 −0
@@ -0,0 +1,82 @@
1 +// QA for the meta/admin surface: screenshots at 390 and 1440, console errors, horizontal overflow, admin auth flow.
2 +// Usage: node qa/meta-screens.mjs [baseUrl] (default http://localhost:8319)
3 +import { mkdirSync } from 'node:fs';
4 +import path from 'node:path';
5 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
6 +
7 +const BASE = process.argv[2] ?? 'http://localhost:8319';
8 +const OUT = path.resolve(import.meta.dirname, 'screens/meta');
9 +mkdirSync(OUT, { recursive: true });
10 +
11 +const PUBLIC = ['/sources', '/methodology', '/status', '/status/data', '/developers', '/about', '/privacy', '/terms', '/admin/login'];
12 +const ADMIN = ['/admin', '/admin/connectors/celestrak_gp', '/admin/raw', '/admin/data-quality', '/admin/entity-resolution', '/admin/costs'];
13 +const VIEWPORTS = [
14 + { name: '390', width: 390, height: 844, isMobile: true, deviceScaleFactor: 2 },
15 + { name: '1440', width: 1440, height: 900, isMobile: false, deviceScaleFactor: 1 },
16 +];
17 +
18 +const browser = await chromium.launch();
19 +const problems = [];
20 +
21 +async function audit(page, route, tag) {
22 + const errors = [];
23 + const onConsole = (m) => m.type() === 'error' && errors.push(m.text());
24 + const onPageError = (e) => errors.push(`pageerror: ${e.message}`);
25 + page.on('console', onConsole);
26 + page.on('pageerror', onPageError);
27 + const res = await page.goto(`${BASE}${route}`, { waitUntil: 'networkidle', timeout: 120_000 });
28 + await page.waitForTimeout(400);
29 + const { sw, iw, title } = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, iw: window.innerWidth, title: document.title }));
30 + const file = `${route.replace(/^\//, '').replace(/[\/?=&]+/g, '_') || 'home'}-${tag}.png`;
31 + await page.screenshot({ path: path.join(OUT, file), fullPage: true });
32 + page.off('console', onConsole);
33 + page.off('pageerror', onPageError);
34 + const status = res?.status();
35 + const line = `${String(status).padEnd(4)} ${tag.padEnd(5)} ${route.padEnd(36)} sw=${sw} iw=${iw} ${title}`;
36 + console.log(line);
37 + if (status !== 200) problems.push(`${route}@${tag}: HTTP ${status}`);
38 + // Mobile emulation zooms out when content is wider than the viewport, inflating innerWidth: compare with the requested width too.
39 + const want = Number(tag);
40 + if (sw > iw || iw !== want || sw > want) problems.push(`${route}@${tag}: horizontal overflow scrollWidth=${sw} innerWidth=${iw} viewport=${want}`);
41 + const realErrors = errors.filter((e) => !/favicon|apple-icon/.test(e));
42 + if (realErrors.length) problems.push(`${route}@${tag}: console errors\n ${realErrors.join('\n ')}`);
43 +}
44 +
45 +for (const vp of VIEWPORTS) {
46 + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, isMobile: vp.isMobile, deviceScaleFactor: vp.deviceScaleFactor, hasTouch: vp.isMobile });
47 + const page = await ctx.newPage();
48 + for (const r of PUBLIC) await audit(page, r, vp.name);
49 +
50 + // Unauthenticated /admin must redirect to /admin/login.
51 + const anon = await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
52 + const landed = new URL(page.url()).pathname;
53 + console.log(`redirect check: /admin -> ${landed} (${anon?.status()})`);
54 + if (landed !== '/admin/login') problems.push(`anonymous /admin landed on ${landed}`);
55 +
56 + // Login with the dev token, then audit admin pages.
57 + await page.goto(`${BASE}/admin/login?next=%2Fadmin%2Fcosts`, { waitUntil: 'networkidle' });
58 + await page.fill('input[name=token]', 'wrong-token');
59 + await page.click('button[type=submit]');
60 + await page.waitForURL(/error=invalid/);
61 + console.log('wrong token -> error shown');
62 + await page.fill('input[name=token]', 'dev-admin-token');
63 + await page.click('button[type=submit]');
64 + await page.waitForURL((u) => u.pathname === '/admin/costs', { timeout: 60_000 });
65 + console.log(`login ok -> ${new URL(page.url()).pathname}`);
66 + const cookies = await ctx.cookies();
67 + const c = cookies.find((k) => k.name === 'si_admin');
68 + if (!c || !c.httpOnly) problems.push('si_admin cookie missing or not httpOnly');
69 + for (const r of ADMIN) await audit(page, r, vp.name);
70 +
71 + // Sign out clears the session.
72 + await page.click('nav[aria-label=Admin] button[type=submit]');
73 + await page.waitForURL(/\/admin\/login/);
74 + const after = await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' });
75 + if (new URL(page.url()).pathname !== '/admin/login') problems.push('after logout /admin did not redirect');
76 + console.log(`logout ok -> ${new URL(page.url()).pathname} (${after?.status()})`);
77 + await ctx.close();
78 +}
79 +await browser.close();
80 +
81 +console.log('\n' + (problems.length ? `PROBLEMS (${problems.length}):\n- ${problems.join('\n- ')}` : 'OK: no overflow, no console errors, auth flow verified'));
82 +process.exit(problems.length ? 1 : 0);
added apps/web/qa/satellite-qa.mjs +70 −0
@@ -0,0 +1,70 @@
1 +/**
2 + * QA for the satellite / launches pages: screenshots at 390 and 1440, console errors, horizontal overflow,
3 + * DOM-order = visual-order check on the satellite detail page.
4 + * node qa/satellite-qa.mjs [baseUrl]
5 + */
6 +import { mkdirSync } from 'node:fs';
7 +import path from 'node:path';
8 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
9 +
10 +const BASE = process.argv[2] ?? 'http://localhost:8319';
11 +const OUT = path.resolve(import.meta.dirname, 'screens/satellite');
12 +mkdirSync(OUT, { recursive: true });
13 +
14 +const PAGES = [
15 + ['satellite-iss', '/satellite/iss-zarya-25544'],
16 + ['satellite-starlink', '/satellite/starlink-38436-100641'],
17 + ['satellite-sputnik', '/satellite/sputnik-1-2'],
18 + ['satellites', '/satellites'],
19 + ['satellites-starlink-active', '/satellites?constellation=starlink&status=ACTIVE'],
20 + ['satellites-empty', '/satellites?status=PLANNED&object_type=DEBRIS'],
21 + ['launches', '/launches'],
22 + ['launches-2026', '/launches?year=2026'],
23 + ['launch-1998-067', '/launch/1998-067'],
24 + ['launch-sites', '/launch-sites'],
25 + ['launch-site-baikonur', '/launch-sites/baikonur-cosmodrome-tyuratam'],
26 +];
27 +const VIEWPORTS = [
28 + ['390', { width: 390, height: 844 }],
29 + ['1440', { width: 1440, height: 900 }],
30 +];
31 +
32 +const browser = await chromium.launch();
33 +let failures = 0;
34 +for (const [vpName, viewport] of VIEWPORTS) {
35 + const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, colorScheme: 'dark' });
36 + for (const [name, url] of PAGES) {
37 + const page = await ctx.newPage();
38 + const errors = [];
39 + page.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
40 + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));
41 + const res = await page.goto(BASE + url, { waitUntil: 'networkidle', timeout: 90_000 });
42 + await page.waitForTimeout(1200);
43 + const metrics = await page.evaluate(() => {
44 + const de = document.documentElement;
45 + const overflow = de.scrollWidth - de.clientWidth;
46 + const wide = [...document.querySelectorAll('body *')].filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1).slice(0, 5).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 2).join('.')}`);
47 + // DOM order == visual order for the detail-page sections
48 + const ids = ['live', 'orbit', 'mission', 'ownership', 'launch', 'history', 'registration', 'sources', 'events', 'related', 'identifiers'];
49 + const tops = ids.map((id) => document.getElementById(id)?.getBoundingClientRect().top).filter((t) => t !== undefined);
50 + const ordered = tops.every((t, i) => i === 0 || t >= tops[i - 1]);
51 + const h1 = document.querySelector('h1');
52 + const h1Top = h1 ? h1.getBoundingClientRect().top + window.scrollY : null;
53 + const firstSectionTop = tops[0] !== undefined ? tops[0] + window.scrollY : null;
54 + const usesOrder = [...document.querySelectorAll('main *')].some((el) => getComputedStyle(el).order !== '0');
55 + return { overflow, wide, ordered, heroFirst: h1Top === null || firstSectionTop === null || h1Top < firstSectionTop, usesOrder, scrollY: window.scrollY, title: document.title };
56 + });
57 + const file = path.join(OUT, `${name}-${vpName}.png`);
58 + await page.screenshot({ path: file, fullPage: true });
59 + const bad = (res?.status() ?? 0) >= 400 || errors.length || metrics.overflow > 0 || !metrics.ordered || !metrics.heroFirst || metrics.usesOrder || metrics.scrollY !== 0;
60 + if (bad) failures++;
61 + console.log(`${bad ? 'FAIL' : ' ok '} ${vpName.padEnd(4)} ${url.padEnd(52)} ${res?.status()} overflow=${metrics.overflow} order=${metrics.ordered ? 'ok' : 'BAD'} hero=${metrics.heroFirst ? 'first' : 'NOT-FIRST'} cssOrder=${metrics.usesOrder ? 'USED' : 'none'} errors=${errors.length}`);
62 + if (errors.length) console.log(' ', errors.slice(0, 3).join('\n '));
63 + if (metrics.wide.length) console.log(' wide:', metrics.wide.join(', '));
64 + await page.close();
65 + }
66 + await ctx.close();
67 +}
68 +await browser.close();
69 +console.log(failures ? `\n${failures} page(s) failed` : '\nall pages passed');
70 +process.exit(failures ? 1 : 0);
added apps/web/qa/screens.mjs +48 −0
@@ -0,0 +1,48 @@
1 +/**
2 + * Production QA sweep: every key route at 390 and 1440 px — HTTP status, console errors, horizontal overflow, screenshot.
3 + * Run: node qa/screens.mjs [BASE_URL] (default http://localhost:8321)
4 + */
5 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
6 +import { mkdirSync } from 'node:fs';
7 +
8 +const BASE = process.argv[2] ?? 'http://localhost:8321';
9 +const OUT = new URL('./screens/sweep/', import.meta.url).pathname;
10 +mkdirSync(OUT, { recursive: true });
11 +
12 +const PAGES = ['/', '/explore', '/search?q=starlink', '/satellites', '/satellites?constellation=starlink&status=ACTIVE', '/satellite/iss-zarya-25544',
13 + '/satellite/sputnik-1-2', '/constellations', '/constellation/starlink', '/operators', '/operator/spacex', '/countries', '/country/canada',
14 + '/launches', '/launch/1998-067', '/launch-sites', '/debris', '/reentries', '/events', '/stats', '/rankings?metric=operators', '/sources',
15 + '/methodology', '/status', '/developers', '/about', '/privacy', '/terms', '/admin', '/does-not-exist'];
16 +const WIDTHS = [390, 1440];
17 +const browser = await chromium.launch();
18 +let failures = 0;
19 +for (const width of WIDTHS) {
20 + const mobile = width < 768;
21 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });
22 + const page = await ctx.newPage();
23 + for (const path of PAGES) {
24 + const errors = [];
25 + const onErr = (e) => errors.push(String(e));
26 + const onCon = (m) => { if (m.type() === 'error') errors.push(m.text()); };
27 + page.on('pageerror', onErr);
28 + page.on('console', onCon);
29 + const t0 = Date.now();
30 + const res = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => ({ status: () => `ERR ${e.message.slice(0, 40)}` }));
31 + await page.waitForTimeout(800);
32 + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth).catch(() => -1);
33 + const expected = path === '/does-not-exist' ? 404 : path === '/admin' ? [200, 307] : 200;
34 + const status = res.status();
35 + const okStatus = Array.isArray(expected) ? expected.includes(status) : status === expected;
36 + const filtered = errors.filter((e) => !/WebGL|webgl|THREE\.WebGLRenderer|favicon/.test(e));
37 + const ok = okStatus && overflow <= 0 && filtered.length === 0;
38 + if (!ok) failures++;
39 + console.log(`${ok ? 'OK ' : 'FAIL'} ${width} ${status} ${String(Date.now() - t0).padStart(5)}ms overflow=${overflow} errors=${filtered.length} ${path}${filtered.length ? ' :: ' + filtered[0].slice(0, 120) : ''}`);
40 + await page.screenshot({ path: `${OUT}${width}-${path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'}.png`, fullPage: false }).catch(() => undefined);
41 + page.off('pageerror', onErr);
42 + page.off('console', onCon);
43 + }
44 + await ctx.close();
45 +}
46 +await browser.close();
47 +console.log(failures ? `\n${failures} failure(s)` : '\nall pages OK');
48 +process.exit(failures ? 1 : 0);
added apps/web/src/app/about/page.tsx +69 −0
@@ -0,0 +1,69 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ATTRIBUTION, DISCLAIMER, MISSION } from '@/components/meta/legal';
4 +import { KeyValues, Prose } from '@/components/meta/prose';
5 +import { Container, PageHeader, Section } from '@/components/ui/section';
6 +import { AUTHOR, CONTACT_EMAIL, SITE_NAME, SITE_URL, routes } from '@/lib/site';
7 +
8 +export const metadata: Metadata = {
9 + title: 'About SatelliteIndex',
10 + description: 'What SatelliteIndex is, who builds it, what it is not, and where the data comes from.',
11 + alternates: { canonical: `${SITE_URL}/about` },
12 + openGraph: { title: 'About | SatelliteIndex', description: 'What SatelliteIndex is, who builds it and where the data comes from.', url: `${SITE_URL}/about` },
13 + twitter: { card: 'summary', title: 'About | SatelliteIndex', description: 'What SatelliteIndex is, who builds it and where the data comes from.' },
14 +};
15 +
16 +export default function AboutPage() {
17 + return (
18 + <Container>
19 + <PageHeader eyebrow="About" title="An index of everything in orbit" lede={MISSION} />
20 +
21 + <Section eyebrow="What it is" title="A canonical, source-attributed layer" className="pt-0">
22 + <Prose>
23 + <p>
24 + {SITE_NAME} keeps one canonical record per object in Earth orbit — payloads, rocket bodies, debris, stations — and links it to the entities around it: the constellation it belongs to, the operator and country responsible for it, the launch that put it there and the site it left from. Orbital element sets are appended, never overwritten, so every object carries its own history.
25 + </p>
26 + <p>
27 + On top of that canonical layer sit derived views: live SGP4 positions, orbit classes, mission types, constellation growth, orbital density by altitude, reentry timelines and rankings. Each derived value is labelled as such and documented in a versioned <Link href={routes.methodology()} className="link">methodology</Link>; each raw value keeps its provenance and is listed with its license on the <Link href={routes.sources()} className="link">sources</Link> page.
28 + </p>
29 + <p>
30 + The whole thing is exposed through a public JSON <Link href={routes.developers()} className="link">API</Link> that the website itself consumes — there is no hidden dataset behind the pages.
31 + </p>
32 + </Prose>
33 + </Section>
34 +
35 + <Section eyebrow="What it is not" title="Honest limits">
36 + <Prose>
37 + <ul>
38 + <li>Not a conjunction-assessment or collision-avoidance service. We publish orbital density as an informational count, never a probability of collision.</li>
39 + <li>Not a reentry-prediction service. Decay dates come from the catalog after the fact; low-perigee watch lists are exactly that — watch lists.</li>
40 + <li>Not authoritative on ownership or mission. Operator, country, mission type and constellation membership are resolved from owner codes, CelesTrak groups and documented name patterns, and can be wrong for individual objects.</li>
41 + <li>Not real-time. Element sets are refreshed on a schedule and positions are propagated from them; freshness is shown on every page and on <Link href={routes.status()} className="link">/status</Link>.</li>
42 + </ul>
43 + <p className="mt-4 text-ink-3">{DISCLAIMER}</p>
44 + </Prose>
45 + </Section>
46 +
47 + <Section eyebrow="Who" title="Built and operated by">
48 + <KeyValues
49 + rows={[
50 + { k: 'Author', v: AUTHOR },
51 + { k: 'Contact', v: <a href={`mailto:${CONTACT_EMAIL}`} className="link">{CONTACT_EMAIL}</a> },
52 + { k: 'Hosting', v: <>Self-hosted on <a href="https://www.maclustr.io" rel="noopener noreferrer" className="link">MacLustr</a>, a cluster of Apple silicon machines; public routes go through the MacLustr Tunnel.</> },
53 + { k: 'Stack', v: 'Python 3.12 · FastAPI · PostgreSQL · Redis · python-sgp4 · Next.js · Tailwind' },
54 + { k: 'Status', v: <>MVP. The API is free without keys for now (<Link href={routes.developers()} className="link">details</Link>).</> },
55 + ]}
56 + />
57 + </Section>
58 +
59 + <Section eyebrow="Sources" title="Attribution">
60 + <Prose>
61 + <p>{ATTRIBUTION}</p>
62 + <p>
63 + Read the <Link href={routes.terms()} className="link">terms</Link> and <Link href={routes.privacy()} className="link">privacy</Link> pages for the legal details; they are short.
64 + </p>
65 + </Prose>
66 + </Section>
67 + </Container>
68 + );
69 +}
added apps/web/src/app/admin/(protected)/connectors/[name]/page.tsx +97 −0
@@ -0,0 +1,97 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EnabledToggle, RunNowButton } from '@/components/admin/actions';
4 +import { AdminError, AdminHeader, JsonInline, RunsTable, SectionTitle } from '@/components/admin/ui';
5 +import { Pagination } from '@/components/ui/pagination';
6 +import { adminApi, safeAdmin } from '@/lib/admin-api';
7 +import { fmtAgo, fmtDateTime } from '@/lib/format';
8 +
9 +export async function generateMetadata({ params }: { params: Promise<{ name: string }> }): Promise<Metadata> {
10 + const { name } = await params;
11 + return { title: `${name} runs` };
12 +}
13 +
14 +export default async function AdminConnectorPage({ params, searchParams }: { params: Promise<{ name: string }>; searchParams: Promise<{ page?: string }> }) {
15 + const { name } = await params;
16 + const sp = await searchParams;
17 + const page = Math.max(1, Number(sp.page) || 1);
18 + const [runsRes, overviewRes] = await Promise.all([safeAdmin(adminApi.connectorRuns(name, page, 50)), safeAdmin(adminApi.overview())]);
19 + const connector = overviewRes.data?.data.connectors.find((c) => c.name === name) ?? null;
20 + const now = Date.now();
21 + return (
22 + <>
23 + <AdminHeader
24 + title={<span className="mono">{name}</span>}
25 + lede={connector ? `${connector.source_name} · ${connector.description ?? ''}` : 'Connector runs and errors'}
26 + action={
27 + <div className="flex flex-wrap items-center gap-2">
28 + <RunNowButton name={name} />
29 + {connector && <EnabledToggle name={name} enabled={connector.enabled} />}
30 + <Link href="/admin" className="text-sm text-accent hover:underline">
31 + ← Overview
32 + </Link>
33 + </div>
34 + }
35 + />
36 + {connector && (
37 + <dl className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
38 + <div>
39 + <dt className="eyebrow">Enabled</dt>
40 + <dd className={connector.enabled ? 'text-active' : 'text-warn'}>{connector.enabled ? 'yes' : 'no'}</dd>
41 + </div>
42 + <div>
43 + <dt className="eyebrow">Last success</dt>
44 + <dd title={fmtDateTime(connector.last_success_at)}>{fmtAgo(connector.last_success_at, now)}</dd>
45 + </div>
46 + <div>
47 + <dt className="eyebrow">Last attempt</dt>
48 + <dd title={fmtDateTime(connector.last_attempt_at)}>{fmtAgo(connector.last_attempt_at, now)}</dd>
49 + </div>
50 + <div>
51 + <dt className="eyebrow">Next run</dt>
52 + <dd>{fmtDateTime(connector.next_run_at)}</dd>
53 + </div>
54 + <div className="col-span-2 md:col-span-4">
55 + <dt className="eyebrow">Config</dt>
56 + <dd>
57 + <JsonInline value={connector.config} />
58 + </dd>
59 + </div>
60 + </dl>
61 + )}
62 +
63 + {runsRes.error !== null ? (
64 + <div className="mt-6">
65 + <AdminError message={runsRes.error} />
66 + </div>
67 + ) : (
68 + <>
69 + <SectionTitle>Runs</SectionTitle>
70 + <RunsTable runs={runsRes.data.data} now={now} showConnector={false} emptyLabel="This connector has never run" />
71 + <Pagination className="mt-4" page={runsRes.data.pagination.page} pages={runsRes.data.pagination.pages} total={runsRes.data.pagination.total} pageSize={runsRes.data.pagination.page_size} makeHref={(p) => `/admin/connectors/${encodeURIComponent(name)}?page=${p}`} />
72 +
73 + <SectionTitle>Recent errors</SectionTitle>
74 + {runsRes.data.errors.length === 0 ? (
75 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No errors recorded</p>
76 + ) : (
77 + <ul className="divide-y divide-rule border-y border-rule">
78 + {runsRes.data.errors.map((e) => (
79 + <li key={e.id} className="grid gap-1 py-2.5 text-sm sm:grid-cols-[160px_140px_minmax(0,1fr)] sm:gap-4">
80 + <span className="text-ink-2" title={fmtDateTime(e.occurred_at)}>
81 + {fmtAgo(e.occurred_at, now)}
82 + </span>
83 + <span className="mono text-xs text-danger">{e.error_type ?? 'Error'}</span>
84 + <span className="min-w-0">
85 + <span className="mono break-words text-xs text-ink">{e.message ?? '—'}</span>
86 + {e.run_id && <span className="mono ml-2 text-[11px] text-ink-3">{e.run_id}</span>}
87 + {e.context && <JsonInline value={e.context} className="mt-1" />}
88 + </span>
89 + </li>
90 + ))}
91 + </ul>
92 + )}
93 + </>
94 + )}
95 + </>
96 + );
97 +}
added apps/web/src/app/admin/(protected)/costs/page.tsx +131 −0
@@ -0,0 +1,131 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, SectionTitle, Tile, fmtBytes } from '@/components/admin/ui';
4 +import { fmtDuration } from '@/components/meta/connectors-table';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtDate, fmtInt, num } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Costs' };
9 +
10 +export default async function AdminCostsPage() {
11 + const res = await safeAdmin(adminApi.costs());
12 + if (res.error !== null) {
13 + return (
14 + <>
15 + <AdminHeader title="Costs" />
16 + <AdminError message={res.error} />
17 + </>
18 + );
19 + }
20 + const d = res.data.data;
21 + const totalRuns = d.per_connector.reduce((a, c) => a + (num(c.runs_30d) ?? 0), 0);
22 + const totalMs = d.per_connector.reduce((a, c) => a + (num(c.total_ms) ?? 0), 0);
23 + const totalFailed = d.per_connector.reduce((a, c) => a + (num(c.failed) ?? 0), 0);
24 + return (
25 + <>
26 + <AdminHeader title="Costs" lede="Compute (connector run time), storage and third-party provider usage over the last 30 days. All current sources are free; no paid provider is enabled." />
27 +
28 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
29 + <Tile label="Runs · 30 d" value={fmtInt(totalRuns)} />
30 + <Tile label="Run time · 30 d" value={fmtDuration(totalMs)} />
31 + <Tile label="Failed runs" value={fmtInt(totalFailed)} tone={totalFailed > 0 ? 'warn' : undefined} />
32 + <Tile label="Database" value={d.storage.database} />
33 + </div>
34 +
35 + <SectionTitle>Per connector · last 30 days</SectionTitle>
36 + {d.per_connector.length === 0 ? (
37 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No runs in the last 30 days</p>
38 + ) : (
39 + <div className="overflow-x-auto">
40 + <table className="data-table stack md:min-w-[720px]">
41 + <thead>
42 + <tr>
43 + <th>Connector</th>
44 + <th className="num">Runs</th>
45 + <th className="num">Records</th>
46 + <th className="num">Total time</th>
47 + <th className="num">Avg / run</th>
48 + <th className="num">Failed</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {d.per_connector.map((c) => {
53 + const runs = num(c.runs_30d) ?? 0;
54 + const ms = num(c.total_ms) ?? 0;
55 + const failed = num(c.failed) ?? 0;
56 + return (
57 + <tr key={c.connector_name}>
58 + <td className="primary">
59 + <Link href={`/admin/connectors/${encodeURIComponent(c.connector_name)}`} className="mono text-sm text-ink hover:text-accent">
60 + {c.connector_name}
61 + </Link>
62 + </td>
63 + <td data-label="Runs" className="num tnum text-sm">
64 + {fmtInt(runs)}
65 + </td>
66 + <td data-label="Records" className="num tnum text-sm text-ink-2">
67 + {fmtInt(c.records)}
68 + </td>
69 + <td data-label="Total time" className="num tnum text-sm text-ink-2">
70 + {fmtDuration(ms)}
71 + </td>
72 + <td data-label="Avg / run" className="num tnum text-sm text-ink-2">
73 + {runs > 0 ? fmtDuration(ms / runs) : '—'}
74 + </td>
75 + <td data-label="Failed" className={`num tnum text-sm ${failed > 0 ? 'text-warn' : 'text-ink-2'}`}>
76 + {fmtInt(failed)}
77 + </td>
78 + </tr>
79 + );
80 + })}
81 + </tbody>
82 + </table>
83 + </div>
84 + )}
85 +
86 + <SectionTitle>Storage</SectionTitle>
87 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
88 + <Tile label="Database" value={d.storage.database} hint="pg_database_size" />
89 + <Tile label="orbital_elements" value={d.storage.orbital_elements} hint="append-only history" />
90 + <Tile label="satellites" value={d.storage.satellites} hint="canonical table + indexes" />
91 + <Tile label="Raw payloads" value={d.storage.raw_uncompressed} hint="uncompressed bytes (stored gzip)" />
92 + </div>
93 +
94 + <SectionTitle>Raw snapshot growth · 30 days</SectionTitle>
95 + {d.raw_growth.length === 0 ? (
96 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No snapshots in the last 30 days</p>
97 + ) : (
98 + <div className="overflow-x-auto">
99 + <table className="data-table md:max-w-md">
100 + <thead>
101 + <tr>
102 + <th>Day</th>
103 + <th className="num">Snapshots</th>
104 + <th className="num">Bytes</th>
105 + </tr>
106 + </thead>
107 + <tbody>
108 + {d.raw_growth.map((g) => (
109 + <tr key={g.day}>
110 + <td className="text-sm">{fmtDate(g.day)}</td>
111 + <td className="num tnum text-sm">{fmtInt(g.snapshots)}</td>
112 + <td className="num tnum text-sm text-ink-2">{fmtBytes(g.bytes)}</td>
113 + </tr>
114 + ))}
115 + </tbody>
116 + </table>
117 + </div>
118 + )}
119 +
120 + <SectionTitle>Paid providers</SectionTitle>
121 + <ul className="divide-y divide-rule border-y border-rule">
122 + {Object.entries(d.paid_providers).map(([k, v]) => (
123 + <li key={k} className="flex items-center justify-between gap-3 py-2.5 text-sm">
124 + <span className="mono text-ink">{k}</span>
125 + <span className={v === 'not enabled' ? 'text-ink-3' : 'text-warn'}>{v}</span>
126 + </li>
127 + ))}
128 + </ul>
129 + </>
130 + );
131 +}
added apps/web/src/app/admin/(protected)/data-quality/page.tsx +113 −0
@@ -0,0 +1,113 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, SectionTitle, Tile } from '@/components/admin/ui';
4 +import { Pagination } from '@/components/ui/pagination';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Data quality' };
9 +
10 +const CHECKS: { key: keyof Awaited<ReturnType<typeof adminApi.dataQuality>>['checks']; label: string; hint: string }[] = [
11 + { key: 'missing_norad', label: 'Missing NORAD', hint: 'satellites without a catalog number' },
12 + { key: 'missing_cospar', label: 'Missing COSPAR', hint: 'no international designator (analyst / unassigned objects)' },
13 + { key: 'active_without_operator', label: 'Active without operator', hint: 'active payloads/stations with no resolved operator' },
14 + { key: 'active_without_country', label: 'Active without country', hint: 'active objects whose owner code has no country' },
15 + { key: 'stale_active', label: 'Stale active', hint: 'active with GP but latest epoch > 30 d' },
16 + { key: 'broken_launch_links', label: 'Broken launch links', hint: 'COSPAR present but no launch row' },
17 + { key: 'active_without_elements', label: 'Active without elements', hint: 'active but never seen in the GP feed' },
18 +];
19 +
20 +export default async function AdminDataQualityPage({ searchParams }: { searchParams: Promise<{ page?: string; flag?: string }> }) {
21 + const sp = await searchParams;
22 + const page = Math.max(1, Number(sp.page) || 1);
23 + const flag = sp.flag || null;
24 + const res = await safeAdmin(adminApi.dataQuality(page, flag, 50));
25 + if (res.error !== null) {
26 + return (
27 + <>
28 + <AdminHeader title="Data quality" />
29 + <AdminError message={res.error} />
30 + </>
31 + );
32 + }
33 + const d = res.data;
34 + const now = Date.now();
35 + const href = (p: number, f: string | null = flag) => `/admin/data-quality?${new URLSearchParams({ ...(f ? { flag: f } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
36 + return (
37 + <>
38 + <AdminHeader title="Data quality" lede="Live checks computed on the canonical satellites table, plus the open flags raised by the connectors during normalization. Flags are never auto-resolved by deleting data." />
39 +
40 + <SectionTitle>Checks (live counts)</SectionTitle>
41 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-7">
42 + {CHECKS.map((c) => {
43 + const v = num(d.checks[c.key]) ?? 0;
44 + return <Tile key={c.key} label={c.label} value={fmtInt(v)} hint={c.hint} tone={v > 0 && (c.key === 'stale_active' || c.key === 'broken_launch_links' || c.key === 'missing_norad') ? 'warn' : undefined} />;
45 + })}
46 + </div>
47 +
48 + <SectionTitle>Open flags by type</SectionTitle>
49 + {d.summary.length === 0 ? (
50 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No open quality flags</p>
51 + ) : (
52 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
53 + <Link href={href(1, null)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${!flag ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
54 + All ({fmtInt(d.summary.reduce((a, s) => a + (num(s.open) ?? 0), 0))})
55 + </Link>
56 + {d.summary.map((s) => (
57 + <Link key={s.flag} href={href(1, s.flag)} className={`mono inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${flag === s.flag ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
58 + {s.flag} · {fmtInt(s.open)}
59 + </Link>
60 + ))}
61 + </div>
62 + )}
63 +
64 + <SectionTitle>Flagged satellites</SectionTitle>
65 + {d.data.length === 0 ? (
66 + <p className="rounded-md border border-dashed border-rule-strong px-4 py-6 text-center text-sm text-ink-3">No flagged rows{flag ? ` for ${flag}` : ''}</p>
67 + ) : (
68 + <>
69 + <div className="overflow-x-auto">
70 + <table className="data-table stack md:min-w-[800px]">
71 + <thead>
72 + <tr>
73 + <th>Satellite</th>
74 + <th>Flag</th>
75 + <th>Detail</th>
76 + <th>Raised</th>
77 + </tr>
78 + </thead>
79 + <tbody>
80 + {d.data.map((f) => (
81 + <tr key={f.id}>
82 + <td className="primary">
83 + {f.slug ? (
84 + <Link href={`/satellite/${f.slug}`} className="text-sm text-ink hover:text-accent">
85 + {f.name ?? f.slug}
86 + </Link>
87 + ) : (
88 + <span className="mono text-xs text-ink-2">
89 + {f.entity_type} {f.entity_id}
90 + </span>
91 + )}
92 + {f.norad_id !== null && <span className="mono ml-2 text-xs text-ink-3">NORAD {f.norad_id}</span>}
93 + </td>
94 + <td data-label="Flag" className="mono text-xs text-warn">
95 + {f.flag}
96 + </td>
97 + <td data-label="Detail" className="text-sm text-ink-2">
98 + {f.detail ?? '—'}
99 + </td>
100 + <td data-label="Raised" title={fmtDateTime(f.created_at)} className="text-sm text-ink-2">
101 + {fmtAgo(f.created_at, now)}
102 + </td>
103 + </tr>
104 + ))}
105 + </tbody>
106 + </table>
107 + </div>
108 + <Pagination className="mt-4" page={d.pagination.page} pages={d.pagination.pages} total={d.pagination.total} pageSize={d.pagination.page_size} makeHref={(p) => href(p)} />
109 + </>
110 + )}
111 + </>
112 + );
113 +}
added apps/web/src/app/admin/(protected)/entity-resolution/page.tsx +109 −0
@@ -0,0 +1,109 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ReviewDecisionButtons } from '@/components/admin/actions';
4 +import type { ReviewItem } from '@/components/admin/types';
5 +import { AdminError, AdminHeader, JsonInline } from '@/components/admin/ui';
6 +import { StatusBadge } from '@/components/ui/badges';
7 +import { Pagination } from '@/components/ui/pagination';
8 +import { adminApi, safeAdmin } from '@/lib/admin-api';
9 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
10 +
11 +export const metadata: Metadata = { title: 'Entity resolution' };
12 +
13 +const STATUSES = ['open', 'merged', 'kept_separate', 'dismissed'] as const;
14 +
15 +function Entity({ label, slug, name, norad, cospar, status }: { label: string; slug: string | null; name: string | null; norad: number | null; cospar: string | null; status: string | null }) {
16 + return (
17 + <div className="min-w-0 rounded-md border border-rule px-3 py-2.5">
18 + <p className="eyebrow">{label}</p>
19 + {slug ? (
20 + <Link href={`/satellite/${slug}`} className="mt-1 block truncate text-sm font-medium text-ink hover:text-accent">
21 + {name ?? slug}
22 + </Link>
23 + ) : (
24 + <p className="mt-1 text-sm text-ink-3">— (entity no longer exists)</p>
25 + )}
26 + <dl className="mono mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-ink-2">
27 + <dt className="text-ink-3">NORAD</dt>
28 + <dd>{norad ?? '—'}</dd>
29 + <dt className="text-ink-3">COSPAR</dt>
30 + <dd>{cospar ?? '—'}</dd>
31 + </dl>
32 + <div className="mt-2">
33 + <StatusBadge status={status} />
34 + </div>
35 + </div>
36 + );
37 +}
38 +
39 +function ReviewCard({ item, now }: { item: ReviewItem; now: number }) {
40 + return (
41 + <li className="border-t border-rule py-5">
42 + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3">
43 + <span className="mono text-ink-2">#{item.id}</span>
44 + <span className="rounded border border-rule px-1.5 py-0.5">{item.kind.replace(/_/g, ' ')}</span>
45 + <span>
46 + confidence <span className="tnum text-ink">{item.confidence === null ? '—' : item.confidence.toFixed(2)}</span>
47 + </span>
48 + <span title={fmtDateTime(item.created_at)}>raised {fmtAgo(item.created_at, now)}</span>
49 + {item.resolved_at && (
50 + <span>
51 + resolved {fmtAgo(item.resolved_at, now)} by {item.resolved_by ?? '?'}
52 + </span>
53 + )}
54 + </div>
55 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
56 + <Entity label="A (kept on merge)" slug={item.a_slug} name={item.a_name} norad={item.a_norad} cospar={item.a_cospar} status={item.a_status} />
57 + <Entity label="B (merged into A)" slug={item.b_slug} name={item.b_name} norad={item.b_norad} cospar={item.b_cospar} status={item.b_status} />
58 + </div>
59 + {item.detail && (
60 + <div className="mt-2">
61 + <JsonInline value={item.detail} />
62 + </div>
63 + )}
64 + {item.status === 'open' && <ReviewDecisionButtons id={item.id} canMerge={!!item.entity_b_id && !!item.b_slug} className="mt-3" />}
65 + </li>
66 + );
67 +}
68 +
69 +export default async function AdminEntityResolutionPage({ searchParams }: { searchParams: Promise<{ page?: string; status?: string }> }) {
70 + const sp = await searchParams;
71 + const page = Math.max(1, Number(sp.page) || 1);
72 + const status = (STATUSES as readonly string[]).includes(sp.status ?? '') ? (sp.status as (typeof STATUSES)[number]) : 'open';
73 + const res = await safeAdmin(adminApi.review(page, status, 25));
74 + const now = Date.now();
75 + const href = (p: number, s: string = status) => `/admin/entity-resolution?${new URLSearchParams({ ...(s !== 'open' ? { status: s } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
76 + return (
77 + <>
78 + <AdminHeader title="Entity resolution" lede="Ambiguous matches from the resolver (NORAD → COSPAR → exact normalized name) are never merged automatically. Decide here; merges move aliases, identifiers and element history from B to A and are recorded in entity_merges with a snapshot of B." />
79 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:px-0">
80 + {STATUSES.map((s) => (
81 + <Link key={s} href={href(1, s)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${status === s ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
82 + {s.replace('_', ' ')}
83 + </Link>
84 + ))}
85 + </div>
86 + {res.error !== null ? (
87 + <div className="mt-6">
88 + <AdminError message={res.error} />
89 + </div>
90 + ) : (
91 + <>
92 + <p className="mt-5 text-xs text-ink-3">
93 + {fmtInt(res.data.pagination.total)} {status.replace('_', ' ')} item{res.data.pagination.total === 1 ? '' : 's'}
94 + </p>
95 + {res.data.data.length === 0 ? (
96 + <p className="mt-3 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Queue is empty</p>
97 + ) : (
98 + <ul className="mt-2 border-b border-rule">
99 + {res.data.data.map((item) => (
100 + <ReviewCard key={item.id} item={item} now={now} />
101 + ))}
102 + </ul>
103 + )}
104 + <Pagination className="mt-4" page={res.data.pagination.page} pages={res.data.pagination.pages} total={res.data.pagination.total} pageSize={res.data.pagination.page_size} makeHref={(p) => href(p)} />
105 + </>
106 + )}
107 + </>
108 + );
109 +}
added apps/web/src/app/admin/(protected)/layout.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import type { ReactNode } from 'react';
2 +import { AdminNav } from '@/components/admin/nav';
3 +import { Container } from '@/components/ui/section';
4 +import { requireAdmin } from '@/lib/admin-auth';
5 +
6 +/** Every page in this group requires the admin session cookie; anonymous visitors are redirected to /admin/login. */
7 +export default async function AdminProtectedLayout({ children }: { children: ReactNode }) {
8 + await requireAdmin();
9 + return (
10 + <Container wide className="py-6 md:py-8">
11 + <div className="grid gap-6 lg:grid-cols-[200px_minmax(0,1fr)] lg:gap-10">
12 + <aside className="min-w-0 lg:sticky lg:top-[calc(var(--header-h)+1.5rem)] lg:self-start">
13 + <p className="eyebrow mb-2 hidden lg:block">Admin console</p>
14 + <AdminNav />
15 + </aside>
16 + <div className="min-w-0">{children}</div>
17 + </div>
18 + </Container>
19 + );
20 +}
added apps/web/src/app/admin/(protected)/page.tsx +130 −0
@@ -0,0 +1,130 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { EnabledToggle, RunNowButton } from '@/components/admin/actions';
4 +import { AdminError, AdminHeader, JsonInline, RunsTable, SectionTitle, Tile, fmtBytes } from '@/components/admin/ui';
5 +import { RunStatusPill, fmtDuration, fmtInterval, fmtUntil } from '@/components/meta/connectors-table';
6 +import { adminApi, safeAdmin } from '@/lib/admin-api';
7 +import { fmtAgo, fmtDateTime, fmtInt, num } from '@/lib/format';
8 +
9 +export const metadata: Metadata = { title: 'Overview' };
10 +
11 +export default async function AdminOverviewPage() {
12 + const res = await safeAdmin(adminApi.overview());
13 + if (res.error !== null) {
14 + return (
15 + <>
16 + <AdminHeader title="Overview" />
17 + <AdminError message={res.error} />
18 + </>
19 + );
20 + }
21 + const d = res.data.data;
22 + const now = Date.now();
23 + const db = d.database;
24 + const openFlags = d.quality.reduce((acc, q) => acc + (num(q.open) ?? 0), 0);
25 + return (
26 + <>
27 + <AdminHeader title="Overview" lede={`Generated ${fmtDateTime(res.data.meta.generated_at)} · request ${res.data.meta.request_id}`} action={<Link href="/status" className="text-sm text-accent hover:underline">Public status →</Link>} />
28 +
29 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4 xl:grid-cols-8">
30 + <Tile label="Satellites" value={fmtInt(db.satellites)} />
31 + <Tile label="Element sets" value={fmtInt(db.elements)} hint="orbital_state rows" />
32 + <Tile label="Events" value={fmtInt(db.events)} />
33 + <Tile label="Raw snapshots" value={fmtInt(db.raw)} hint={`${fmtBytes(db.raw_bytes)} uncompressed`} href="/admin/raw" />
34 + <Tile label="Database" value={fmtBytes(db.db_bytes)} hint={`${fmtInt(db.db_connections)} connections`} />
35 + <Tile label="Redis" value={d.redis ? 'ok' : 'down'} tone={d.redis ? 'ok' : 'danger'} hint={`queue depth ${fmtInt(d.queue_depth)}`} />
36 + <Tile label="Open quality flags" value={fmtInt(openFlags)} tone={openFlags > 0 ? 'warn' : undefined} href="/admin/data-quality" />
37 + <Tile label="Review queue" value={fmtInt(d.review_open)} tone={d.review_open > 0 ? 'warn' : undefined} href="/admin/entity-resolution" />
38 + </div>
39 +
40 + <SectionTitle>Connectors</SectionTitle>
41 + <div className="overflow-x-auto">
42 + <table className="data-table stack md:min-w-[1100px]">
43 + <thead>
44 + <tr>
45 + <th>Connector</th>
46 + <th>Status</th>
47 + <th>Last success</th>
48 + <th>Next run</th>
49 + <th className="num">Duration</th>
50 + <th className="num">Failures</th>
51 + <th className="num">Errors 7 d</th>
52 + <th>Circuit</th>
53 + <th>Actions</th>
54 + </tr>
55 + </thead>
56 + <tbody>
57 + {d.connectors.map((c) => {
58 + const circuitOpen = !!c.circuit_open_until && new Date(c.circuit_open_until).getTime() > now;
59 + return (
60 + <tr key={c.name}>
61 + <td className="primary">
62 + <Link href={`/admin/connectors/${encodeURIComponent(c.name)}`} className="mono text-sm text-ink hover:text-accent">
63 + {c.name}
64 + </Link>
65 + <div className="text-xs text-ink-3">
66 + {c.source_name} · every {fmtInterval(c.interval_seconds)} · priority {c.priority}
67 + {!c.enabled && <span className="ml-2 text-warn">disabled</span>}
68 + </div>
69 + {c.description && <div className="mt-0.5 max-w-md text-xs text-ink-3">{c.description}</div>}
70 + </td>
71 + <td data-label="Status">
72 + <RunStatusPill status={c.last_status} />
73 + </td>
74 + <td data-label="Last success" title={fmtDateTime(c.last_success_at)} className="text-sm">
75 + {fmtAgo(c.last_success_at, now)}
76 + </td>
77 + <td data-label="Next run" title={fmtDateTime(c.next_run_at)} className="text-sm text-ink-2">
78 + {c.enabled ? fmtUntil(c.next_run_at, now) : '—'}
79 + </td>
80 + <td data-label="Duration" className="num tnum text-sm text-ink-2">
81 + {fmtDuration(c.last_duration_ms)}
82 + </td>
83 + <td data-label="Failures" className={`num tnum text-sm ${c.consecutive_failures > 0 ? 'text-warn' : 'text-ink-2'}`}>
84 + {fmtInt(c.consecutive_failures)}
85 + </td>
86 + <td data-label="Errors 7 d" className={`num tnum text-sm ${(num(c.errors_7d) ?? 0) > 0 ? 'text-warn' : 'text-ink-2'}`}>
87 + {fmtInt(c.errors_7d)}
88 + </td>
89 + <td data-label="Circuit">{circuitOpen ? <span className="text-xs text-danger">open · resets {fmtUntil(c.circuit_open_until, now)}</span> : <span className="text-xs text-ink-3">closed</span>}</td>
90 + <td data-label="Actions" className="primary md:[grid-column:auto]">
91 + <div className="flex flex-wrap items-center gap-2">
92 + <RunNowButton name={c.name} />
93 + <EnabledToggle name={c.name} enabled={c.enabled} />
94 + </div>
95 + </td>
96 + </tr>
97 + );
98 + })}
99 + </tbody>
100 + </table>
101 + </div>
102 + <p className="mt-2 text-xs text-ink-3">
103 + &ldquo;Run now&rdquo; pushes a job to the Redis queue consumed by the scheduler (<code className="mono">si schedule</code>). If Redis is down the job is not visible to the scheduler and the button says so. Enabling a connector also resets its circuit breaker.
104 + </p>
105 +
106 + {d.failed_jobs.length > 0 && (
107 + <>
108 + <SectionTitle>Failed jobs</SectionTitle>
109 + <RunsTable runs={d.failed_jobs} now={now} />
110 + </>
111 + )}
112 +
113 + <SectionTitle>Recent runs</SectionTitle>
114 + <RunsTable runs={d.recent_runs} now={now} />
115 + {d.recent_runs.some((r) => r.meta) && (
116 + <details className="mt-3 text-xs text-ink-3">
117 + <summary className="cursor-pointer py-1 hover:text-ink">Run metadata (latest 5)</summary>
118 + <ul className="mt-2 space-y-1">
119 + {d.recent_runs.slice(0, 5).map((r) => (
120 + <li key={r.id} className="grid gap-1 sm:grid-cols-[220px_minmax(0,1fr)]">
121 + <span className="mono">{r.connector_name}</span>
122 + <JsonInline value={r.meta} />
123 + </li>
124 + ))}
125 + </ul>
126 + </details>
127 + )}
128 + </>
129 + );
130 +}
added apps/web/src/app/admin/(protected)/raw/[id]/page.tsx +67 −0
@@ -0,0 +1,67 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { AdminError, AdminHeader, fmtBytes } from '@/components/admin/ui';
5 +import { KeyValues } from '@/components/meta/prose';
6 +import { adminApi, AdminApiError } from '@/lib/admin-api';
7 +import { fmtDateTime, fmtInt } from '@/lib/format';
8 +
9 +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
10 + const { id } = await params;
11 + return { title: `Raw ${id}` };
12 +}
13 +
14 +export default async function AdminRawRecordPage({ params }: { params: Promise<{ id: string }> }) {
15 + const { id } = await params;
16 + let record;
17 + try {
18 + record = (await adminApi.rawRecord(id)).data;
19 + } catch (e) {
20 + if (e instanceof AdminApiError && e.notFound) notFound();
21 + return (
22 + <>
23 + <AdminHeader title="Raw record" />
24 + <AdminError message={(e as Error).message} />
25 + </>
26 + );
27 + }
28 + return (
29 + <>
30 + <AdminHeader
31 + title={<span className="mono break-all">{record.source_native_id ?? record.id}</span>}
32 + lede={`${record.connector_name} · fetched ${fmtDateTime(record.fetched_at)}`}
33 + action={
34 + <Link href={`/admin/raw?connector=${encodeURIComponent(record.connector_name)}`} className="text-sm text-accent hover:underline">
35 + ← Raw records
36 + </Link>
37 + }
38 + />
39 + <KeyValues
40 + rows={[
41 + { k: 'Record id', v: <span className="mono text-xs">{record.id}</span> },
42 + { k: 'Run', v: record.run_id ? <Link href={`/admin/connectors/${encodeURIComponent(record.connector_name)}`} className="mono text-xs link">{record.run_id}</Link> : '—' },
43 + { k: 'Source', v: <span className="mono text-xs">{record.source_id}</span> },
44 + { k: 'Source URL', v: record.source_url ? <a href={record.source_url} rel="noopener noreferrer" target="_blank" className="link mono break-all text-xs">{record.source_url}</a> : '—' },
45 + { k: 'Storage path', v: <span className="mono break-all text-xs">{record.storage_path ?? '— (payload not kept: unchanged upstream)'}</span> },
46 + { k: 'Content type', v: <span className="mono text-xs">{record.content_type ?? '—'}</span> },
47 + { k: 'Size / records', v: `${fmtBytes(record.byte_size)} · ${fmtInt(record.record_count)} records` },
48 + { k: 'Payload hash', v: <span className="mono break-all text-xs">{record.payload_hash ?? '—'}</span> },
49 + { k: 'Processing', v: <span className={record.processing_status === 'processed' ? 'text-active' : record.processing_status === 'failed' ? 'text-danger' : ''}>{record.processing_status ?? '—'}{record.processed_at ? ` · ${fmtDateTime(record.processed_at)}` : ''}</span> },
50 + ...(record.error ? [{ k: 'Error', v: <span className="mono text-xs text-danger">{record.error}</span> }] : []),
51 + ]}
52 + />
53 +
54 + <div className="mt-6 flex items-end justify-between gap-3">
55 + <h2 className="text-base font-semibold tracking-tight md:text-lg">Payload preview</h2>
56 + {record.truncated && <span className="rounded border border-warn/40 bg-warn-soft px-2 py-0.5 text-xs text-warn">truncated at 200 KB</span>}
57 + </div>
58 + {record.preview === null ? (
59 + <p className="mt-3 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">Payload file unavailable on this host (no storage path or file missing under SI_DATA_DIR/raw)</p>
60 + ) : (
61 + <pre className="scrollbar-thin mt-3 max-h-[70vh] overflow-auto rounded-md border border-rule bg-plane-2 p-3 text-[12px] leading-relaxed text-ink">
62 + <code className="mono">{record.preview}</code>
63 + </pre>
64 + )}
65 + </>
66 + );
67 +}
added apps/web/src/app/admin/(protected)/raw/page.tsx +99 −0
@@ -0,0 +1,99 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AdminError, AdminHeader, fmtBytes } from '@/components/admin/ui';
4 +import { Pagination } from '@/components/ui/pagination';
5 +import { adminApi, safeAdmin } from '@/lib/admin-api';
6 +import { fmtAgo, fmtDateTime, fmtInt } from '@/lib/format';
7 +
8 +export const metadata: Metadata = { title: 'Raw records' };
9 +
10 +const KNOWN_CONNECTORS = ['celestrak_gp', 'celestrak_groups', 'celestrak_satcat', 'derived_analytics'];
11 +
12 +export default async function AdminRawPage({ searchParams }: { searchParams: Promise<{ page?: string; connector?: string }> }) {
13 + const sp = await searchParams;
14 + const page = Math.max(1, Number(sp.page) || 1);
15 + const connector = sp.connector || null;
16 + const [res, overview] = await Promise.all([safeAdmin(adminApi.raw(page, connector, 50)), safeAdmin(adminApi.overview())]);
17 + const connectors = overview.data ? overview.data.data.connectors.map((c) => c.name) : KNOWN_CONNECTORS;
18 + const now = Date.now();
19 + const href = (p: number, c: string | null = connector) => `/admin/raw?${new URLSearchParams({ ...(c ? { connector: c } : {}), ...(p > 1 ? { page: String(p) } : {}) }).toString()}`;
20 + return (
21 + <>
22 + <AdminHeader title="Raw records" lede="Every upstream payload is stored as an immutable gzip snapshot before normalization (SI_DATA_DIR/raw). Nothing here is ever rewritten." />
23 + <div className="no-scrollbar -mx-4 flex gap-1.5 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:px-0">
24 + <Link href={href(1, null)} className={`inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${!connector ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
25 + All connectors
26 + </Link>
27 + {connectors.map((c) => (
28 + <Link key={c} href={href(1, c)} className={`mono inline-flex min-h-9 shrink-0 items-center rounded-md border px-3 text-xs ${connector === c ? 'border-accent/40 bg-accent-soft text-accent' : 'border-rule text-ink-2 hover:bg-plane-2'}`}>
29 + {c}
30 + </Link>
31 + ))}
32 + </div>
33 +
34 + {res.error !== null ? (
35 + <div className="mt-6">
36 + <AdminError message={res.error} />
37 + </div>
38 + ) : res.data.data.length === 0 ? (
39 + <p className="mt-6 rounded-md border border-dashed border-rule-strong px-4 py-8 text-center text-sm text-ink-3">No raw records{connector ? ` for ${connector}` : ''}</p>
40 + ) : (
41 + <>
42 + <div className="mt-5 overflow-x-auto">
43 + <table className="data-table stack md:min-w-[960px]">
44 + <thead>
45 + <tr>
46 + <th>Record</th>
47 + <th>Connector</th>
48 + <th>Fetched</th>
49 + <th>Processing</th>
50 + <th className="num">Records</th>
51 + <th className="num">Size</th>
52 + <th>Type</th>
53 + </tr>
54 + </thead>
55 + <tbody>
56 + {res.data.data.map((r) => (
57 + <tr key={r.id}>
58 + <td className="primary">
59 + <Link href={`/admin/raw/${encodeURIComponent(r.id)}`} className="mono text-sm text-ink hover:text-accent">
60 + {r.source_native_id ?? r.id}
61 + </Link>
62 + <div className="mono truncate text-[11px] text-ink-3" title={r.id}>
63 + {r.id}
64 + </div>
65 + </td>
66 + <td data-label="Connector" className="mono text-xs text-ink-2">
67 + {r.connector_name}
68 + </td>
69 + <td data-label="Fetched" title={fmtDateTime(r.fetched_at)} className="text-sm">
70 + {fmtAgo(r.fetched_at, now)}
71 + </td>
72 + <td data-label="Processing">
73 + <span className={`mono text-xs ${r.processing_status === 'processed' ? 'text-active' : r.processing_status === 'failed' ? 'text-danger' : 'text-ink-2'}`}>{r.processing_status ?? '—'}</span>
74 + {r.error && (
75 + <span className="mono block max-w-[240px] truncate text-[11px] text-danger" title={r.error}>
76 + {r.error}
77 + </span>
78 + )}
79 + </td>
80 + <td data-label="Records" className="num tnum text-sm">
81 + {fmtInt(r.record_count)}
82 + </td>
83 + <td data-label="Size" className="num tnum text-sm text-ink-2">
84 + {fmtBytes(r.byte_size)}
85 + </td>
86 + <td data-label="Type" className="mono text-xs text-ink-3">
87 + {r.content_type ?? '—'}
88 + </td>
89 + </tr>
90 + ))}
91 + </tbody>
92 + </table>
93 + </div>
94 + <Pagination className="mt-4" page={res.data.pagination.page} pages={res.data.pagination.pages} total={res.data.pagination.total} pageSize={res.data.pagination.page_size} makeHref={(p) => href(p)} />
95 + </>
96 + )}
97 + </>
98 + );
99 +}
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 +
4 +/** Everything under /admin is private: never indexed, never cached (see `headers()` in next.config.ts). */
5 +export const metadata: Metadata = {
6 + title: { default: 'Admin', template: '%s · Admin | SatelliteIndex' },
7 + robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },
8 +};
9 +
10 +export const dynamic = 'force-dynamic';
11 +
12 +export default function AdminRootLayout({ children }: { children: ReactNode }) {
13 + return <>{children}</>;
14 +}
added apps/web/src/app/admin/login/page.tsx +47 −0
@@ -0,0 +1,47 @@
1 +import type { Metadata } from 'next';
2 +import { redirect } from 'next/navigation';
3 +import { LogoMark } from '@/components/brand/logo';
4 +import { Container } from '@/components/ui/section';
5 +import { isAdmin, safeNextPath } from '@/lib/admin-auth';
6 +
7 +export const metadata: Metadata = { title: 'Sign in', robots: { index: false, follow: false } };
8 +
9 +export default async function AdminLoginPage({ searchParams }: { searchParams: Promise<{ error?: string; next?: string }> }) {
10 + const sp = await searchParams;
11 + const next = safeNextPath(sp.next);
12 + if (await isAdmin()) redirect(next);
13 + const error = sp.error === 'invalid' ? 'That token is not valid.' : sp.error === 'unconfigured' ? 'SI_ADMIN_TOKEN is not configured on the server — the admin is disabled.' : null;
14 + return (
15 + <Container className="flex min-h-[60vh] items-center justify-center py-12">
16 + <form method="post" action="/api/admin/login" className="w-full max-w-sm rounded-lg border border-rule bg-plane/60 p-6">
17 + <div className="flex items-center gap-2">
18 + <LogoMark size={22} className="text-ink" />
19 + <p className="eyebrow">SatelliteIndex admin</p>
20 + </div>
21 + <h1 className="mt-3 text-xl font-semibold tracking-tight">Sign in</h1>
22 + <p className="mt-1 text-sm text-ink-2">Paste the operator token. It is verified on the server and never stored in the browser — only a hashed session cookie is set.</p>
23 + <label htmlFor="token" className="eyebrow mt-5 block">
24 + Admin token
25 + </label>
26 + <input
27 + id="token"
28 + name="token"
29 + type="password"
30 + autoComplete="current-password"
31 + required
32 + autoFocus
33 + className="mono mt-1.5 block h-11 w-full rounded-md border border-rule bg-plane-2 px-3 text-sm text-ink outline-none focus:border-accent"
34 + />
35 + <input type="hidden" name="next" value={next} />
36 + {error && (
37 + <p role="alert" className="mt-3 text-sm text-danger">
38 + {error}
39 + </p>
40 + )}
41 + <button type="submit" className="mt-4 inline-flex h-11 w-full items-center justify-center rounded-md bg-accent text-sm font-semibold text-accent-ink hover:brightness-110">
42 + Continue
43 + </button>
44 + </form>
45 + </Container>
46 + );
47 +}
added apps/web/src/app/api/admin/connectors/[name]/enabled/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextResponse } from 'next/server';
2 +import { adminApi, AdminApiError } from '@/lib/admin-api';
3 +import { isAdmin } from '@/lib/admin-auth';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** Enable / disable a connector: body `{ "enabled": boolean }` → FastAPI `/admin/connectors/{name}/enabled`. */
8 +export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }): Promise<Response> {
9 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
10 + const { name } = await ctx.params;
11 + const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
12 + if (!body || typeof body.enabled !== 'boolean') return NextResponse.json({ error: { title: 'Invalid body', detail: '`enabled` must be a boolean', status: 422 } }, { status: 422 });
13 + try {
14 + const out = await adminApi.setEnabled(name, body.enabled);
15 + return NextResponse.json(out.data);
16 + } catch (e) {
17 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
18 + return NextResponse.json({ error: { title: 'Toggle failed', detail: (e as Error).message, status } }, { status });
19 + }
20 +}
added apps/web/src/app/api/admin/connectors/[name]/run/route.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { NextResponse } from 'next/server';
2 +import { adminApi, AdminApiError } from '@/lib/admin-api';
3 +import { isAdmin } from '@/lib/admin-auth';
4 +
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** Queue a manual connector run (proxied to FastAPI with the server-side token). Requires the admin session cookie. */
8 +export async function POST(_req: Request, ctx: { params: Promise<{ name: string }> }): Promise<Response> {
9 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
10 + const { name } = await ctx.params;
11 + try {
12 + const out = await adminApi.triggerRun(name);
13 + return NextResponse.json(out.data);
14 + } catch (e) {
15 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
16 + return NextResponse.json({ error: { title: 'Run request failed', detail: (e as Error).message, status } }, { status });
17 + }
18 +}
added apps/web/src/app/api/admin/login/route.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { NextResponse } from 'next/server';
2 +import { ADMIN_COOKIE, cookieOptions, hashToken, safeNextPath, verifyToken } from '@/lib/admin-auth';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +/** Login form target: compares the posted token with SI_ADMIN_TOKEN server-side and sets the httpOnly session cookie. */
7 +export async function POST(req: Request): Promise<Response> {
8 + const form = await req.formData().catch(() => null);
9 + const token = typeof form?.get('token') === 'string' ? String(form.get('token')).trim() : '';
10 + const next = safeNextPath(typeof form?.get('next') === 'string' ? String(form.get('next')) : null);
11 + if (!verifyToken(token)) {
12 + const url = new URL('/admin/login', req.url);
13 + url.searchParams.set('error', process.env.SI_ADMIN_TOKEN ? 'invalid' : 'unconfigured');
14 + if (next !== '/admin') url.searchParams.set('next', next);
15 + return NextResponse.redirect(url, 303);
16 + }
17 + const res = NextResponse.redirect(new URL(next, req.url), 303);
18 + res.cookies.set(ADMIN_COOKIE, hashToken(token), cookieOptions());
19 + return res;
20 +}
added apps/web/src/app/api/admin/logout/route.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { NextResponse } from 'next/server';
2 +import { ADMIN_COOKIE } from '@/lib/admin-auth';
3 +
4 +export const dynamic = 'force-dynamic';
5 +
6 +export async function POST(req: Request): Promise<Response> {
7 + const res = NextResponse.redirect(new URL('/admin/login', req.url), 303);
8 + res.cookies.set(ADMIN_COOKIE, '', { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 0 });
9 + return res;
10 +}
added apps/web/src/app/api/admin/review/[id]/route.ts +27 −0
@@ -0,0 +1,27 @@
1 +import { NextResponse } from 'next/server';
2 +import type { ReviewDecision } from '@/components/admin/types';
3 +import { adminApi, AdminApiError } from '@/lib/admin-api';
4 +import { isAdmin } from '@/lib/admin-auth';
5 +
6 +export const dynamic = 'force-dynamic';
7 +
8 +const DECISIONS: ReviewDecision[] = ['merged', 'kept_separate', 'dismissed'];
9 +
10 +/** Entity-resolution decision: body `{ "decision": "merged" | "kept_separate" | "dismissed" }`. Merges are audited server-side (entity_merges). */
11 +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }): Promise<Response> {
12 + if (!(await isAdmin())) return NextResponse.json({ error: { title: 'Unauthorized', status: 401 } }, { status: 401 });
13 + const { id } = await ctx.params;
14 + const itemId = Number(id);
15 + const body = (await req.json().catch(() => null)) as { decision?: unknown } | null;
16 + const decision = body?.decision;
17 + if (!Number.isInteger(itemId) || typeof decision !== 'string' || !DECISIONS.includes(decision as ReviewDecision)) {
18 + return NextResponse.json({ error: { title: 'Invalid request', detail: 'decision must be merged | kept_separate | dismissed', status: 422 } }, { status: 422 });
19 + }
20 + try {
21 + const out = await adminApi.decide(itemId, decision as ReviewDecision, 'admin-web');
22 + return NextResponse.json(out.data);
23 + } catch (e) {
24 + const status = e instanceof AdminApiError && e.status > 0 ? e.status : 502;
25 + return NextResponse.json({ error: { title: 'Decision failed', detail: (e as Error).message, status } }, { status });
26 + }
27 +}
added apps/web/src/app/apple-icon.tsx +20 −0
@@ -0,0 +1,20 @@
1 +import { ImageResponse } from 'next/og';
2 +
3 +/** 180×180 PNG generated from the SatelliteIndex mark (planet + inclined ring + cyan node) on the space background. */
4 +export const size = { width: 180, height: 180 };
5 +export const contentType = 'image/png';
6 +
7 +export default function AppleIcon() {
8 + return new ImageResponse(
9 + (
10 + <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#060912', borderRadius: 40 }}>
11 + <svg width="132" height="132" viewBox="0 0 32 32" fill="none">
12 + <circle cx="16" cy="16" r="7.5" fill="#eaf0ff" opacity="0.92" />
13 + <ellipse cx="16" cy="16" rx="14" ry="5.2" stroke="#eaf0ff" strokeWidth="1.6" transform="rotate(-24 16 16)" opacity="0.85" />
14 + <circle cx="27.4" cy="9.6" r="2.3" fill="#38d3ff" />
15 + </svg>
16 + </div>
17 + ),
18 + { ...size },
19 + );
20 +}
added apps/web/src/app/constellation/[slug]/opengraph-image.tsx +55 −0
@@ -0,0 +1,55 @@
1 +import { ImageResponse } from 'next/og';
2 +import { api, safe } from '@/lib/api';
3 +import { fmtInt } from '@/lib/format';
4 +
5 +export const alt = 'Constellation summary — SatelliteIndex';
6 +export const size = { width: 1200, height: 630 };
7 +export const contentType = 'image/png';
8 +
9 +const ORBIT_HEX: Record<string, string> = { LEO: '#38d3ff', MEO: '#8f7dff', GEO: '#f5b544', HEO: '#ff7ab6' };
10 +
11 +export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const res = await safe(api.constellation(slug));
14 + const d = res?.data ?? null;
15 + const orbit = d?.orbit_class ?? 'MIXED';
16 + const color = ORBIT_HEX[orbit] ?? '#7c869e';
17 +
18 + return new ImageResponse(
19 + (
20 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: 'linear-gradient(135deg, #060912 0%, #0b1020 60%, #111830 100%)', color: '#eaf0ff', fontFamily: 'sans-serif' }}>
21 + <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
22 + <div style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: 26, letterSpacing: 4, textTransform: 'uppercase', color: '#a3aec8' }}>
23 + <div style={{ width: 14, height: 14, borderRadius: 999, background: '#38d3ff' }} />
24 + <div>SatelliteIndex · Constellation</div>
25 + </div>
26 + <div style={{ display: 'flex', padding: '8px 18px', borderRadius: 8, border: `2px solid ${color}`, color, fontSize: 28, fontWeight: 700 }}>{orbit}</div>
27 + </div>
28 +
29 + {d ? (
30 + <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
31 + <div style={{ fontSize: d.name.length > 28 ? 60 : 84, fontWeight: 700, letterSpacing: -2, lineHeight: 1 }}>{d.name}</div>
32 + <div style={{ fontSize: 34, color: '#a3aec8' }}>{`${d.operator_name ?? 'Operator unavailable'}${d.country_name ? ` · ${d.country_name}` : ''}`}</div>
33 + </div>
34 + ) : (
35 + <div style={{ fontSize: 72, fontWeight: 700 }}>Constellation</div>
36 + )}
37 +
38 + <div style={{ display: 'flex', gap: 56, borderTop: '1px solid rgba(160,180,230,0.28)', paddingTop: 28 }}>
39 + {[
40 + { label: 'Active satellites', value: d ? fmtInt(d.active) : '—', c: '#38d17f' },
41 + { label: 'Total launched', value: d ? fmtInt(d.total) : '—', c: '#eaf0ff' },
42 + { label: 'Launched 365 d', value: d ? fmtInt(d.launched_last_365d) : '—', c: '#eaf0ff' },
43 + { label: 'Launches', value: d ? fmtInt(d.launches) : '—', c: '#eaf0ff' },
44 + ].map((k) => (
45 + <div key={k.label} style={{ display: 'flex', flexDirection: 'column' }}>
46 + <div style={{ fontSize: 20, letterSpacing: 3, textTransform: 'uppercase', color: '#6b7694' }}>{k.label}</div>
47 + <div style={{ fontSize: 56, fontWeight: 700, color: k.c, marginTop: 6 }}>{k.value}</div>
48 + </div>
49 + ))}
50 + </div>
51 + </div>
52 + ),
53 + size,
54 + );
55 +}
modified apps/web/src/app/constellation/[slug]/page.tsx +2 −2
@@ -112,7 +112,7 @@ export default async function ConstellationPage({ params }: Props) {
112 112 <Block eyebrow="Orbits" title="Orbital shells" id="shells">
113 113 <OrbitalShells d={d} />
114 114 </Block>
115 − <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches" action={{ href: routes.launches(`constellation=${encodeURIComponent(d.slug)}`), label: 'All launches' }}>
115 + <Block eyebrow="Launches" title={`Launch history · ${fmtInt(d.launches)} launches`} id="launches">
116 116 <LaunchesTable rows={d.launches_list} showActive />
117 117 {d.launches_list.length < (num(d.launches) ?? 0) && <p className="mt-2 text-xs text-ink-3">Showing the {fmtInt(d.launches_list.length)} most recent launches.</p>}
118 118 </Block>
@@ -124,7 +124,7 @@ export default async function ConstellationPage({ params }: Props) {
124 124 <DecaysByMonth rows={d.decays_by_month} />
125 125 </Block>
126 126 )}
127 − <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`constellation=${encodeURIComponent(d.slug)}`), label: 'All events' }}>
127 + <Block eyebrow="Timeline" title="Events" id="events" action={{ href: routes.events(`entity=${encodeURIComponent(d.id)}`), label: 'All events' }}>
128 128 <EventsList events={d.events} />
129 129 </Block>
130 130 </div>
modified apps/web/src/app/constellations/page.tsx +12 −11
@@ -19,6 +19,7 @@ const SORTS: { value: string | undefined; label: string }[] = [
19 19 { value: 'activity', label: 'Activity' },
20 20 { value: 'name', label: 'Name' },
21 21 ];
22 +const SORT_NOUN: Record<string, string> = { '': 'active satellites', total: 'total satellites launched', growth: 'satellites launched in the last 365 days', activity: 'activity score', name: 'name' };
22 23 const SERVICES = ['communications', 'earth-observation', 'navigation', 'iot', 'weather', 'military', 'technology', 'station', 'science'];
23 24 const ORBITS = ['LEO', 'MEO', 'GEO', 'HEO', 'MIXED'];
24 25
@@ -53,7 +54,7 @@ export default async function ConstellationsPage({ searchParams }: { searchParam
53 54 lede={
54 55 total !== null ? (
55 56 <>
56 − {fmtInt(total)} constellations tracked{params.service || params.orbit ? ' in this selection' : ''}, ranked by {SORTS.find((s) => (s.value ?? '') === (params.sort ?? ''))?.label.toLowerCase() ?? 'active satellites'}.
57 + {fmtInt(total)} constellations tracked{params.service || params.orbit ? ' in this selection' : ''}, ranked by {SORT_NOUN[params.sort ?? ''] ?? 'active satellites'}.
57 58 {rows.length > 0 && <> The {fmtInt(rows.length)} shown on this page account for {fmtInt(activeSum)} active satellites.</>} Membership is derived from curated name patterns and CelesTrak groups — see the <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.
58 59 </>
59 60 ) : (
@@ -97,21 +98,21 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
97 98 <table className="data-table stack">
98 99 <thead>
99 100 <tr>
100 − <th className="num">#</th>
101 + <th className="num max-md:hidden!">#</th>
101 102 <th>Constellation</th>
102 103 <th>Operator</th>
103 104 <th>Service</th>
104 105 <th>Orbit</th>
105 106 <th className="num">Active</th>
106 − <th className="num">On orbit</th>
107 + <th className="num max-md:hidden!">On orbit</th>
107 108 <th className="num">Total</th>
108 109 <th className="num">365 d</th>
109 − <th className="num">30 d</th>
110 + <th className="num max-md:hidden!">30 d</th>
110 111 <th className="num">
111 112 <span className="inline-flex items-center gap-1">Activity <Derived /></span>
112 113 </th>
113 − <th className="num">Median perigee</th>
114 − <th>First launch</th>
114 + <th className="num max-md:hidden!">Median perigee</th>
115 + <th className="max-md:hidden!">First launch</th>
115 116 <th>Last launch</th>
116 117 </tr>
117 118 </thead>
@@ -119,7 +120,7 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
119 120 {rows.length === 0 && <EmptyRow colSpan={14}>No constellation matches these filters.</EmptyRow>}
120 121 {rows.map((c, i) => (
121 122 <tr key={c.id}>
122 − <td data-label="Rank" className="num mono text-xs text-ink-3">{offset + i + 1}</td>
123 + <td data-label="Rank" className="num mono text-xs text-ink-3 max-md:hidden!">{offset + i + 1}</td>
123 124 <td className="primary" data-label="Constellation">
124 125 <Link href={routes.constellation(c.slug)} className="link font-medium">{c.name}</Link>
125 126 {c.country_code && <span className="mono ml-2 text-xs text-ink-3">{c.country_code}</span>}
@@ -128,13 +129,13 @@ function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset
128 129 <td data-label="Service" className="text-ink-2">{titleCase(c.service_type)}</td>
129 130 <td data-label="Orbit"><OrbitBadge orbitClass={c.orbit_class} /></td>
130 131 <td data-label="Active" className="num tnum font-medium text-active">{fmtInt(c.active)}</td>
131 − <td data-label="On orbit" className="num tnum">{fmtInt(c.on_orbit)}</td>
132 + <td data-label="On orbit" className="num tnum max-md:hidden!">{fmtInt(c.on_orbit)}</td>
132 133 <td data-label="Total" className="num tnum">{fmtInt(c.total)}</td>
133 134 <td data-label="Launched 365 d" className="num tnum">{fmtInt(c.launched_last_365d)}</td>
134 − <td data-label="Launched 30 d" className="num tnum">{fmtInt(c.launched_last_30d)}</td>
135 + <td data-label="Launched 30 d" className="num tnum max-md:hidden!">{fmtInt(c.launched_last_30d)}</td>
135 136 <td data-label="Activity score" className="num tnum text-accent-2">{fmt1(c.activity_score)}</td>
136 − <td data-label="Median perigee" className="num tnum">{fmtKm(c.median_perigee_km)}</td>
137 − <td data-label="First launch" className="tnum text-ink-2">{fmtDate(c.first_launch)}</td>
137 + <td data-label="Median perigee" className="num tnum max-md:hidden!">{fmtKm(c.median_perigee_km)}</td>
138 + <td data-label="First launch" className="tnum text-ink-2 max-md:hidden!">{fmtDate(c.first_launch)}</td>
138 139 <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(c.last_launch)}</td>
139 140 </tr>
140 141 ))}
added apps/web/src/app/countries/page.tsx +135 −0
@@ -0,0 +1,135 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { HBars } from '@/components/charts/charts';
4 +import { ChipRow, EmptyRow, ScrollTable, entityMetadata, first, type Params } from '@/components/entities/shared';
5 +import { Container, PageHeader } from '@/components/ui/section';
6 +import { Unavailable } from '@/components/ui/unavailable';
7 +import { api, safe } from '@/lib/api';
8 +import { fmtInt, num } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import type { CountryRow } from '@/lib/types';
11 +
12 +type SearchParams = Record<string, string | string[] | undefined>;
13 +
14 +const SORTS: { value: string | undefined; label: string; key: keyof CountryRow; noun: string }[] = [
15 + { value: undefined, label: 'Active payloads', key: 'active_payloads', noun: 'active payloads' },
16 + { value: 'objects', label: 'Objects on orbit', key: 'objects_on_orbit', noun: 'objects on orbit' },
17 + { value: 'debris', label: 'Debris', key: 'debris_on_orbit', noun: 'debris fragments on orbit' },
18 + { value: 'launches', label: 'Launches', key: 'launches', noun: 'launches' },
19 + { value: 'name', label: 'Name', key: 'name', noun: 'name' },
20 +];
21 +
22 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<Metadata> {
23 + const sort = first((await searchParams).sort);
24 + const s = SORTS.find((x) => (x.value ?? '') === (sort ?? '')) ?? SORTS[0]!;
25 + return entityMetadata({
26 + title: `Countries in orbit — ranked by ${s.noun}`,
27 + description: 'Every country with catalogued objects in Earth orbit, ranked by active payloads, objects on orbit, debris, rocket bodies, launches and operators — with regional breakdown.',
28 + path: routes.countries(),
29 + });
30 +}
31 +
32 +export default async function CountriesPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
33 + const sp = await searchParams;
34 + const params: Params = { sort: first(sp.sort) };
35 + const sortDef = SORTS.find((x) => (x.value ?? '') === (params.sort ?? '')) ?? SORTS[0]!;
36 + const res = await safe(api.countries(params.sort ?? 'active'));
37 + const rows = res?.data ?? [];
38 + const base = routes.countries();
39 +
40 + const metricKey = sortDef.key === 'name' ? 'active_payloads' : sortDef.key;
41 + const metricNoun = sortDef.key === 'name' ? 'active payloads' : sortDef.noun;
42 + const top10 = rows.length ? [...rows].sort((a, b) => (num(b[metricKey] as never) ?? 0) - (num(a[metricKey] as never) ?? 0)).slice(0, 10).map((c) => ({ label: c.name, value: num(c[metricKey] as never) ?? 0, href: routes.country(c.slug) })) : [];
43 + const byRegion = new Map<string, number>();
44 + for (const c of rows) byRegion.set(c.region ?? 'Unattributed', (byRegion.get(c.region ?? 'Unattributed') ?? 0) + (num(c[metricKey] as never) ?? 0));
45 + const regionData = [...byRegion.entries()].map(([label, value], i) => ({ label, value, color: `var(--series-${(i % 8) + 1})` })).sort((a, b) => b.value - a.value);
46 +
47 + const totals = rows.reduce(
48 + (t, c) => ({ active: t.active + (num(c.active_payloads) ?? 0), objects: t.objects + (num(c.objects_on_orbit) ?? 0), debris: t.debris + (num(c.debris_on_orbit) ?? 0) }),
49 + { active: 0, objects: 0, debris: 0 },
50 + );
51 +
52 + return (
53 + <Container wide>
54 + <PageHeader
55 + eyebrow="Countries"
56 + title="Countries in orbit"
57 + lede={
58 + res ? (
59 + <>
60 + {fmtInt(rows.length)} countries and jurisdictions hold catalogued objects in orbit — together {fmtInt(totals.active)} active payloads, {fmtInt(totals.objects)} objects on orbit and {fmtInt(totals.debris)} tracked debris fragments. Attribution follows SATCAT owner codes mapped to ISO 3166 (joint programmes are listed under their lead entry); see the{' '}
61 + <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.
62 + </>
63 + ) : (
64 + 'Country rankings are temporarily unavailable.'
65 + )
66 + }
67 + />
68 +
69 + <div className="border-y border-rule py-2">
70 + <ChipRow label="Rank by" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS.map((s) => ({ value: s.value, label: s.label }))} />
71 + </div>
72 +
73 + {res && rows.length > 0 && (
74 + <div className="grid gap-8 border-b border-rule py-8 lg:grid-cols-2">
75 + <div>
76 + <p className="eyebrow mb-3">Top 10 · {metricNoun}</p>
77 + <HBars data={top10} barHeight={30} />
78 + </div>
79 + <div>
80 + <p className="eyebrow mb-3">By region · {metricNoun}</p>
81 + <HBars data={regionData} barHeight={30} />
82 + <p className="mt-3 text-xs text-ink-3">Regions aggregate the countries listed below; multinational organisations (e.g. ESA, Intelsat) appear as their own entry.</p>
83 + </div>
84 + </div>
85 + )}
86 +
87 + <div className="py-6">{!res ? <Unavailable what="Country rankings" /> : <CountryTable rows={rows} />}</div>
88 + </Container>
89 + );
90 +}
91 +
92 +function CountryTable({ rows }: { rows: CountryRow[] }) {
93 + return (
94 + <ScrollTable>
95 + <table className="data-table stack">
96 + <thead>
97 + <tr>
98 + <th className="num">#</th>
99 + <th>Country</th>
100 + <th>Region</th>
101 + <th className="num">Active payloads</th>
102 + <th className="num max-md:hidden!">Payloads on orbit</th>
103 + <th className="num">Objects on orbit</th>
104 + <th className="num">Debris</th>
105 + <th className="num max-md:hidden!">Rocket bodies</th>
106 + <th className="num">Launches</th>
107 + <th className="num max-md:hidden!">Operators</th>
108 + <th className="num">Payloads 365 d</th>
109 + </tr>
110 + </thead>
111 + <tbody>
112 + {rows.length === 0 && <EmptyRow colSpan={11}>No countries returned.</EmptyRow>}
113 + {rows.map((c, i) => (
114 + <tr key={c.code}>
115 + <td data-label="Rank" className="num mono text-xs text-ink-3">{i + 1}</td>
116 + <td className="primary" data-label="Country">
117 + <Link href={routes.country(c.slug)} className="link font-medium">{c.name}</Link>
118 + <span className="mono ml-2 text-xs text-ink-3">{c.iso3 ?? c.code}</span>
119 + </td>
120 + <td data-label="Region" className="text-ink-2">{c.region ?? '—'}</td>
121 + <td data-label="Active payloads" className="num tnum font-medium text-active">{fmtInt(c.active_payloads)}</td>
122 + <td data-label="Payloads on orbit" className="num tnum max-md:hidden!">{fmtInt(c.on_orbit_payloads)}</td>
123 + <td data-label="Objects on orbit" className="num tnum">{fmtInt(c.objects_on_orbit)}</td>
124 + <td data-label="Debris" className="num tnum text-warn">{fmtInt(c.debris_on_orbit)}</td>
125 + <td data-label="Rocket bodies" className="num tnum max-md:hidden!">{fmtInt(c.rocket_bodies_on_orbit)}</td>
126 + <td data-label="Launches" className="num tnum">{fmtInt(c.launches)}</td>
127 + <td data-label="Operators" className="num tnum max-md:hidden!">{fmtInt(c.operators)}</td>
128 + <td data-label="Payloads 365 d" className="num tnum">{fmtInt(c.payloads_last_365d)}</td>
129 + </tr>
130 + ))}
131 + </tbody>
132 + </table>
133 + </ScrollTable>
134 + );
135 +}
added apps/web/src/app/country/[slug]/page.tsx +245 −0
Large diff (245 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/debris/page.tsx +183 −0
Large diff (183 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/developers/page.tsx +172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/events/[id]/page.tsx +171 −0
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/events/page.tsx +117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/explore/page.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/launch-sites/[slug]/page.tsx +120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/launch-sites/page.tsx +85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/launch/[cospar]/page.tsx +192 −0
Large diff (192 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/launches/loading.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/launches/page.tsx +84 −0
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/layout.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/manifest.ts +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/methodology/page.tsx +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/opengraph-image.tsx +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/operator/[slug]/page.tsx +149 −0
Large diff (149 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/operators/page.tsx +150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/app/page.tsx +73 −6
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/privacy/page.tsx +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/rankings/page.tsx +115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/reentries/page.tsx +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/satellite/[slug]/opengraph-image.tsx +80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/satellite/[slug]/page.tsx +136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/satellites/loading.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/satellites/page.tsx +99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/search/page.tsx +91 −0
Large diff (91 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.xml/route.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap/[shard]/route.ts +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sources/page.tsx +207 −0
Large diff (207 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/stats/page.tsx +100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/status/page.tsx +137 −0
Large diff (137 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/terms/page.tsx +66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/actions.tsx +129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/nav.tsx +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/types.ts +156 −0
Large diff (156 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/admin/ui.tsx +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/charts/charts.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/events/event-badge.tsx +98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/events/event-timeline.tsx +90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/globe/earth.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/focus-search.tsx +130 −0
Large diff (130 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/globe-fallback.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/globe-filters.tsx +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/globe-legend.tsx +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/globe-scene.tsx +205 −0
Large diff (205 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/globe.tsx +199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/lazy-globe.tsx +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/globe/sat-panel.tsx +135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/globe/satellite-points.tsx +15 −11
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/activity.tsx +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/density.tsx +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/featured.tsx +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/hero.tsx +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/metrics.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/rankings.tsx +93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/search-button.tsx +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/home/sources.tsx +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/launches/launch-filters.tsx +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/launches/launch-timeline.tsx +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/launches/launches-table.tsx +56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/launches/sites-map.tsx +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/connectors-table.tsx +146 −0
Large diff (146 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/endpoints.ts +88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/legal.ts +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/methodology-sections.tsx +245 −0
Large diff (245 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/methodology-tables.tsx +182 −0
Large diff (182 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/prose.tsx +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/meta/sitemap-data.ts +98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/explorer-filters.tsx +174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/results-table.tsx +49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/search-box.tsx +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/sections.tsx +325 −0
Large diff (325 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/satellite/skeleton.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/search/results.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/search/search-form.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/rankings-config.tsx +206 −0
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/stats/shared.tsx +7 −2
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stats/stats-density.tsx +89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin-api.ts +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/admin-auth.ts +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/types.ts +1 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/analytics/stats.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/api/common.py +22 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/api/routers/events.py +2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/api/routers/launches.py +16 −7
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/api/routers/satellites.py +15 −6
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/api/routers/search.py +4 −4
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/connectors/derived.py +29 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
modified src/satelliteindex/connectors/orbital/celestrak/gp.py +2 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme