SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%

deploy: nightly backup (pg_dump + ClickHouse native exports, off-node copy to BHS128)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent be6a3c4

44 changed files +2,860 −15

added apps/web/Dockerfile +30 −0
@@ -0,0 +1,30 @@
1 +# InternetPressure.io — web (Next 16 standalone). Build from the REPOSITORY ROOT:
2 +# docker build -f apps/web/Dockerfile -t internetpressure-web .
3 +# The dev mock (apps/web/mock) is excluded via apps/web/Dockerfile.dockerignore and is never part of the image.
4 +
5 +FROM node:22-alpine AS deps
6 +RUN corepack enable && corepack prepare pnpm@11 --activate
7 +WORKDIR /repo
8 +COPY apps/web/package.json apps/web/pnpm-lock.yaml apps/web/
9 +RUN cd apps/web && pnpm install --frozen-lockfile
10 +
11 +FROM node:22-alpine AS build
12 +RUN corepack enable && corepack prepare pnpm@11 --activate
13 +WORKDIR /repo
14 +COPY --from=deps /repo/apps/web/node_modules apps/web/node_modules
15 +COPY apps/web apps/web
16 +# Static pages (/methodology, /api) tolerate an unreachable API at build time (they fall back to documented defaults).
17 +ENV NEXT_TELEMETRY_DISABLED=1
18 +RUN cd apps/web && pnpm build
19 +
20 +FROM node:22-alpine AS runtime
21 +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=8351 HOSTNAME=0.0.0.0
22 +WORKDIR /app
23 +RUN addgroup -S web && adduser -S web -G web
24 +# outputFileTracingRoot = repo root → the standalone server lives at apps/web/server.js
25 +COPY --from=build --chown=web:web /repo/apps/web/.next/standalone ./
26 +COPY --from=build --chown=web:web /repo/apps/web/.next/static ./apps/web/.next/static
27 +COPY --from=build --chown=web:web /repo/apps/web/public ./apps/web/public
28 +USER web
29 +EXPOSE 8351
30 +CMD ["node", "apps/web/server.js"]
added apps/web/Dockerfile.dockerignore +15 −0
@@ -0,0 +1,15 @@
1 +# Applies to `docker build -f apps/web/Dockerfile .` (BuildKit picks <Dockerfile>.dockerignore).
2 +**/node_modules
3 +**/.next
4 +**/.git
5 +**/qa/screens
6 +apps/web/mock
7 +apps/web/qa
8 +apps/api
9 +services
10 +infra
11 +deploy
12 +docs
13 +data
14 +tests
15 +**/.env*
added apps/web/README.md +126 −0
@@ -0,0 +1,126 @@
1 +# InternetPressure.io — web frontend
2 +
3 +Next 16 (App Router, React 19, TypeScript strict, Tailwind 4) instrument for **The real-time pressure gauge for the
4 +Internet**. It renders the public API (`docs/API.md`) server-side, then follows the `/api/v1/live` SSE stream in small
5 +client islands. Dark-first, tabular numerals, no motion without a real update (spec §66), visible "Instrument degraded"
6 +state when `internal_status ≠ ok` (spec §57).
7 +
8 +## Structure
9 +
10 +```
11 +apps/web
12 +├── next.config.ts env loading (../../.env), /api → API_URL rewrite, security + cache headers, standalone output
13 +├── Dockerfile multi-stage node:22-alpine → .next/standalone (port 8351)
14 +├── mock/server.mjs DEV-ONLY API fixtures for every endpoint of docs/API.md, incl. SSE (never shipped)
15 +├── scripts/copy-maplibre-worker.mjs copies MapLibre 6 module worker to public/maplibre (predev/prebuild)
16 +├── qa/screens.mjs Playwright screenshots + overflow/console checks at 1440×900 and 390×844
17 +├── public/logo.svg gauge glyph (also inline in components/chrome/Logo.tsx and src/app/icon.svg)
18 +└── src
19 + ├── app
20 + │ ├── layout.tsx html.dark, Geist fonts, TimeProvider (UTC/local)
21 + │ ├── (site)/ public chrome: layout (LiveProvider + header/footer), all public routes
22 + │ ├── admin/ separate layout (token gate, no SSE) + 10 sections
23 + │ ├── opengraph-image.tsx OG image from the live index · robots.ts · sitemap.ts · error.tsx · not-found.tsx
24 + │ └── globals.css design tokens, pressure scale CSS variables, .tbl dense tables
25 + ├── components
26 + │ ├── chrome/ Header, NavLinks, Footer, Logo, LiveIndicator, DegradedBanner, TimeToggle, Search (⌘K)
27 + │ ├── gauge/ Gauge (the number), ExplainPanel (explain rows → components → signals)
28 + │ ├── home/ ComponentRows, Fronts, IncidentsList, Ticker, Clock, RegionsTable, ProbeStrip
29 + │ ├── map/ MapIsland (lazy, ssr:false) → WorldMap (MapLibre), ModeSelector, modes
30 + │ ├── charts/ echarts core registration, useEChart, HistoryChart, SeriesChart
31 + │ ├── detail/ ScopeHeader, ComponentGrid, Tables (targets/probes/latency matrix), ScopeCharts
32 + │ ├── incidents/ routes/ service/ history/ bgp/ targets/ admin/
33 + │ └── ui/ primitives (Section, LevelBadge, Delta, Bar, Sparkline, Stat…), AnimatedNumber
34 + └── lib
35 + ├── types.ts TypeScript mirror of docs/API.md (public + admin)
36 + ├── api.ts server-side fetch (API_URL_INTERNAL, no-store, 404 → notFound)
37 + ├── live.tsx one EventSource per page, backoff reconnect, sliced store (useSyncExternalStore)
38 + ├── pressure.ts levels, colours, pressureColor(level|value), component labels
39 + ├── format.ts numerals, deltas (true minus), durations, formatTime(ts, utc|local)
40 + ├── time.tsx UTC/local toggle persisted in localStorage; <Time/> component
41 + ├── geo.ts great-circle interpolation for Pressure Front arcs
42 + ├── iso-numeric-to-alpha2.ts world-atlas numeric ids → ISO alpha-2
43 + └── admin-fetch.ts X-IP-Admin-Token client (token in sessionStorage)
44 +```
45 +
46 +## Routes
47 +
48 +| Route | Consumes |
49 +|---|---|
50 +| `/` | `pressure/global`, `ticker`, `pressure/regions`, `pressure/countries`, `fronts`, `incidents?status=active`, `probes`, `pressure/history?range=24h`, `latency` + live stream |
51 +| `/internet/[region]` | `pressure/region/{id}` |
52 +| `/country/[cc]` | `pressure/country/{cc}`, `latency` (matrix rows of its region) |
53 +| `/asn/[asn]`, `/asns` | `pressure/asn/{asn}`, `asns` |
54 +| `/service/[slug]`, `/services` | `service/{slug}`, `services` |
55 +| `/routes` | `routes/pairs`, `probes`, `targets`, `routes?probe&target` (client refetch on change) |
56 +| `/event/[slug]`, `/incidents` | `incident/{slug}`, `incidents?status=…` |
57 +| `/history`, `/history/[year]`, `/history/[year]/[month]` | `history/summary[?year[&month]]` |
58 +| `/probes` | `probes`, `pressure/regions`, `latency` |
59 +| `/targets` | `targets` |
60 +| `/bgp` | `bgp/stats` + live `bgp_stats` |
61 +| `/methodology` (static, revalidate 300) | `methodology` + prose from SPEC §3–8, 20, 40, 57, 66 |
62 +| `/api` (static) | none (documentation) |
63 +| `/admin/*` | `/api/admin/*` (client-side, token header) |
64 +| `/opengraph-image`, `/sitemap.xml`, `/robots.txt` | `pressure/global`; countries/regions/asns/services/incidents/history |
65 +
66 +All dynamic pages export `dynamic = 'force-dynamic'` so `next build` never bakes API data.
67 +
68 +## Environment variables
69 +
70 +| Variable | Default | Used by |
71 +|---|---|---|
72 +| `API_URL` | `http://127.0.0.1:8352` | `next.config.ts` rewrite of browser `/api/*` calls (dev; in production the edge Caddy routes `/api` directly) |
73 +| `API_URL_INTERNAL` | falls back to `API_URL` | server components / OG image / sitemap fetching the API inside the network |
74 +| `NEXT_PUBLIC_SITE_URL` | `https://www.internetpressure.io` | canonical URLs, OG, sitemap, API examples |
75 +
76 +A single `.env` at the repository root is loaded by `next.config.ts` (like the sibling projects).
77 +
78 +## Development
79 +
80 +```bash
81 +pnpm install
82 +node mock/server.mjs # dev-only fixtures on :8352 (MOCK_DEGRADED=1 to see the degraded state; admin token dev-admin-token)
83 +pnpm dev # http://localhost:8351 (predev copies the MapLibre worker to public/maplibre)
84 +pnpm typecheck && pnpm lint
85 +node qa/screens.mjs # screenshots into qa/screens/ (Playwright borrowed from ~/Desktop/uqo-eval/node_modules)
86 +```
87 +
88 +The mock is **development-only**: it is excluded from the Docker image (`Dockerfile.dockerignore`) and must never be
89 +deployed. The production site only ever talks to `apps/api`.
90 +
91 +## Build & Docker
92 +
93 +```bash
94 +pnpm build # requires the API (or the mock) reachable at API_URL_INTERNAL for the two static pages
95 +pnpm start # next start -p 8351 -H 0.0.0.0
96 +
97 +# from the repository root
98 +docker build -f apps/web/Dockerfile -t internetpressure-web .
99 +docker run --rm -p 8351:8351 -e API_URL_INTERNAL=http://api:8352 internetpressure-web
100 +```
101 +
102 +`output: 'standalone'` with `outputFileTracingRoot` at the repo root puts the server at
103 +`.next/standalone/apps/web/server.js`; the Dockerfile copies `.next/static` and `public` next to it.
104 +
105 +## Notes on MapLibre 6
106 +
107 +MapLibre GL ≥ 6 is ESM-only and spawns a *module* worker resolved from `import.meta.url`. Bundled by Turbopack that URL
108 +points at a chunk, the worker 404s and the map stays black. `scripts/copy-maplibre-worker.mjs` copies
109 +`maplibre-gl-worker.mjs` + `maplibre-gl-shared.mjs` to `public/maplibre/` and `WorldMap.tsx` calls
110 +`setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')`. Basemap: `https://tiles.openfreemap.org/styles/dark` (no key) with
111 +an offline fallback style; countries from `world-atlas` 110m; only observed countries are coloured.
112 +
113 +## Contract notes (docs/API.md)
114 +
115 +Everything consumed is in API.md. Places where the frontend had to interpret the contract:
116 +
117 +- `GET /pressure/region/{id}` and `/pressure/country/{cc}`: `probes`/`targets`/`incidents` are **counts** in the list
118 + objects and **lists** in the detail objects — typed separately (`RegionDetailResponse`, `CountryDetailResponse`).
119 +- `GET /history/summary` without `month`: the month rows (`months`) are assumed to carry `{month, min, max, avg, events}`
120 + (mirrors the `days` shape with `month: "YYYY-MM"`); API.md only says "per-month rows".
121 +- `fronts[].status` values are not enumerated in API.md; the map animates the arc only for `developing`/`active`.
122 +- The map's "Packet loss" mode derives per-region loss from `latency.matrix[].loss_pct` (source view) because there is no
123 + loss component in `regions[].components`; countries are neutral in that mode.
124 +- Admin `GET /incidents` rows are assumed to carry `review` and `note` (the PATCH body fields) so the review state can
125 + be displayed; API.md does not list them on the GET response.
126 +- Admin `GET /annotations` rows assumed `{id?, ts, author?, scope_type, scope_id, text}`.
modified apps/web/mock/server.mjs +1 −1
@@ -351,7 +351,7 @@ const allIncidents = () => [...activeIncidents(), ...RESOLVED];
351 351 function incidentDetail(inc) {
352 352 const start = new Date(inc.started_at).getTime();
353 353 const end = inc.ended_at ? new Date(inc.ended_at).getTime() : now();
354 − const step = Math.max(60, Math.round((end - start + 30 * 60_000) / 180 / 60) * 60);
354 + const step = Math.max(60, Math.round((end - start + 30 * 60_000) / 1000 / 180 / 60) * 60);
355 355 const points = [];
356 356 for (let t = start - 30 * 60_000; t <= end; t += step * 1000) {
357 357 const frac = clamp((t - start) / Math.max(1, end - start), 0, 1);
added apps/web/src/app/(site)/api/page.tsx +104 −0
@@ -0,0 +1,104 @@
1 +import type { Metadata } from 'next';
2 +import { Section } from '@/components/ui/primitives';
3 +import { SITE_URL } from '@/lib/site';
4 +
5 +export const revalidate = 3600;
6 +export const metadata: Metadata = { title: 'API', description: 'Public, rate-limited JSON API and Server-Sent Events stream of InternetPressure.io — the same API the site uses.' };
7 +
8 +const ENDPOINTS: { path: string; desc: string; params?: string }[] = [
9 + { path: '/api/v1/status', desc: 'Instrument health: engine, ingest, probes, BGP, stores, internal_status.' },
10 + { path: '/api/v1/pressure/global', desc: 'The index: pressure, level, Δ1h/Δ24h, velocity, acceleration, volatility, confidence, coverage, components with drivers, explain rows, 1-h sparkline.' },
11 + { path: '/api/v1/pressure/history', desc: 'Server-side aggregated series for any scope.', params: 'scope_type=global|region|country|asn|service|component · scope_id · range=1h|6h|24h|7d|30d|1y' },
12 + { path: '/api/v1/pressure/regions', desc: 'All regions with pressure, level, Δ1h, component scores, probes, targets, coverage.' },
13 + { path: '/api/v1/pressure/region/{id}', desc: 'Region detail: 24-h history, 7-d baseline, incidents, top ASNs/services, probes, latency matrix.' },
14 + { path: '/api/v1/pressure/countries', desc: 'Countries where we hold at least one probe or anchored target — nothing else is pretended.' },
15 + { path: '/api/v1/pressure/country/{cc}', desc: 'Country detail with history, baseline, incidents, ASNs, services, probes and targets.' },
16 + { path: '/api/v1/asns', desc: 'Autonomous systems index.' },
17 + { path: '/api/v1/pressure/asn/{asn}', desc: 'ASN detail: components, BGP stats and 24-h series, regions observed, targets, incidents.' },
18 + { path: '/api/v1/services', desc: 'Services index with observed availability and vendor status indicator.' },
19 + { path: '/api/v1/service/{slug}', desc: 'Observed vs vendor, discrepancy, probe × target matrix, affected regions.' },
20 + { path: '/api/v1/targets', desc: 'Target registry.' },
21 + { path: '/api/v1/target/{id}', desc: 'Target detail: latest measurement per probe, 24-h series, DNS resolvers.' },
22 + { path: '/api/v1/probes', desc: 'Probe network: status, last seen, version, uptime, clock offset, capabilities.' },
23 + { path: '/api/v1/incidents', desc: 'Incident list.', params: 'status=active|resolved|all · limit · offset' },
24 + { path: '/api/v1/incident/{slug}', desc: 'Incident detail: timeline, evidence, series with global overlay, probes, targets, BGP, annotations.' },
25 + { path: '/api/v1/fronts', desc: 'Active Pressure Fronts (source → destination arcs).' },
26 + { path: '/api/v1/bgp/stats', desc: 'BGP rates vs baseline, collectors, 1-h series, top origins.' },
27 + { path: '/api/v1/latency', desc: 'Global latency medians, inter-region matrix, per-probe view.' },
28 + { path: '/api/v1/ticker', desc: 'The live ticker counters.' },
29 + { path: '/api/v1/routes', desc: 'Route Explorer: baseline vs current traceroute, diff, 24-h hashes, 7-d share.', params: 'probe · target' },
30 + { path: '/api/v1/routes/pairs', desc: 'Sampled (probe, target) pairs with 24-h change counts.' },
31 + { path: '/api/v1/history/summary', desc: 'History explorer summary.', params: 'year · month' },
32 + { path: '/api/v1/explain', desc: 'Deep explainability: components → signals with current, median, MAD, robust z, samples, stress, contribution.' },
33 + { path: '/api/v1/methodology', desc: 'Public copy of the scoring configuration.' },
34 + { path: '/api/v1/search', desc: 'Search countries, regions, ASNs, services, targets, incidents.', params: 'q' },
35 + { path: '/api/v1/live', desc: 'Server-Sent Events: snapshot, global_pressure_update, regional_pressure_update, ticker, bgp_stats, probe_stats, incident_created/updated, service_degradation, front_update, internal_status.' },
36 +];
37 +
38 +export default function ApiPage() {
39 + return (
40 + <div className="pb-8">
41 + <header className="pt-6 pb-4">
42 + <p className="label">Developers</p>
43 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Public API</h1>
44 + <p className="mt-2 max-w-[780px] text-[14px] leading-relaxed text-ink-2">The website is built on the same API. It is free and rate-limited (120 requests per minute per IP, 4 concurrent SSE connections), returns JSON with UTC ISO-8601 timestamps and plain numbers, and is served with <span className="num">Cache-Control: no-store</span> except long-range history (30 s). Unknown scopes return <span className="num">404 {'{"error":"not_found"}'}</span>. Higher limits, webhooks and historical exports: contact@spboucher.ai.</p>
45 + </header>
46 +
47 + <Section label="Quick start">
48 + <pre className="num overflow-x-auto rounded-[4px] border border-line bg-panel p-4 text-[12px] leading-relaxed text-ink">
49 + {`# the index right now
50 +curl -s ${SITE_URL}/api/v1/pressure/global | jq '{pressure, level, delta_1h, confidence}'
51 +
52 +# 24 h of global pressure, 1-minute step
53 +curl -s "${SITE_URL}/api/v1/pressure/history?scope_type=global&range=24h" | jq '.summary'
54 +
55 +# a country, an ASN, a service
56 +curl -s ${SITE_URL}/api/v1/pressure/country/CA | jq '.pressure'
57 +curl -s ${SITE_URL}/api/v1/pressure/asn/13335 | jq '.bgp'
58 +curl -s ${SITE_URL}/api/v1/service/cloudflare | jq '{observed, vendor_status, discrepancy}'
59 +
60 +# live stream (SSE)
61 +curl -N ${SITE_URL}/api/v1/live`}
62 + </pre>
63 + </Section>
64 +
65 + <Section label="Endpoints" right={<span>{ENDPOINTS.length} public routes</span>}>
66 + <div className="scroll-x -mx-3 px-3">
67 + <table className="tbl min-w-[640px]">
68 + <thead>
69 + <tr>
70 + <th>GET</th>
71 + <th>Returns</th>
72 + <th className="hidden md:table-cell">Query</th>
73 + </tr>
74 + </thead>
75 + <tbody>
76 + {ENDPOINTS.map((e) => (
77 + <tr key={e.path}>
78 + <td className="num align-top text-ink">
79 + <a href={e.path.includes('{') ? undefined : e.path} className={e.path.includes('{') ? '' : 'hover:text-accent'}>
80 + {e.path}
81 + </a>
82 + </td>
83 + <td className="whitespace-normal align-top text-ink-2">{e.desc}</td>
84 + <td className="num hidden whitespace-normal align-top text-[11px] text-ink-3 md:table-cell">{e.params ?? ''}</td>
85 + </tr>
86 + ))}
87 + </tbody>
88 + </table>
89 + </div>
90 + </Section>
91 +
92 + <Section label="Levels & status">
93 + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 text-[13px] text-ink-2 md:grid-cols-2">
94 + <p>
95 + Every <span className="num text-ink">level</span> field is one of <span className="num text-ink">calm ≤10 · normal ≤25 · elevated ≤40 · stressed ≤55 · high ≤70 · severe ≤85 · extreme ≤100</span>; <span className="num text-ink">level_label</span> is its human label.
96 + </p>
97 + <p>
98 + <span className="num text-ink">internal_status</span> is <span className="num">ok</span>, <span className="num">degraded</span> (too few fresh probes, stale BGP or unhealthy stores — the score is frozen and <span className="num">stale: true</span>) or <span className="num">stale</span> (engine has not run). Clients must show a degraded state instead of interpreting a frozen number.
99 + </p>
100 + </div>
101 + </Section>
102 + </div>
103 + );
104 +}
added apps/web/src/app/(site)/bgp/page.tsx +100 −0
@@ -0,0 +1,100 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { BgpLive } from '@/components/bgp/BgpLive';
4 +import { BgpSeriesChart } from '@/components/bgp/BgpSeriesChart';
5 +import { Section, StatusDot } from '@/components/ui/primitives';
6 +import { apiGet } from '@/lib/api';
7 +import { fmt, fmtInt } from '@/lib/format';
8 +import { Time } from '@/lib/time';
9 +import type { BgpStats } from '@/lib/types';
10 +
11 +export const dynamic = 'force-dynamic';
12 +export const metadata: Metadata = { title: 'BGP', description: 'Live BGP updates and withdrawals per second versus baseline, collector health, one-hour series and top origin ASNs.' };
13 +
14 +export default async function BgpPage() {
15 + const b = await apiGet<BgpStats>('/api/v1/bgp/stats');
16 + return (
17 + <div className="pb-8">
18 + <header className="pt-6 pb-4">
19 + <p className="label">Routing component</p>
20 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">BGP</h1>
21 + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Announcements and withdrawals from RIPE RIS Live collectors, normalised and compared with a rolling baseline. Withdrawal spikes, origin-ASN changes and collector disagreement feed the Routing component.</p>
22 + </header>
23 + <Section label="Live rates" right={<span>updated every 5 s from real counters</span>}>
24 + <BgpLive initial={b} />
25 + </Section>
26 + <Section label="Last hour" right={<span>per minute</span>}>
27 + <BgpSeriesChart series={b.series_1h} />
28 + </Section>
29 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
30 + <Section label="Collectors" right={<span>{fmtInt(b.collectors.length)} · {fmtInt(b.peers)} peers</span>} className="min-w-0">
31 + <div className="scroll-x -mx-3 px-3">
32 + <table className="tbl">
33 + <thead>
34 + <tr>
35 + <th>Collector</th>
36 + <th className="hidden sm:table-cell">Location</th>
37 + <th className="r">Ann./s</th>
38 + <th className="r">Wd./s</th>
39 + <th className="r hidden sm:table-cell">Peers</th>
40 + <th className="r">Last msg</th>
41 + <th>Fresh</th>
42 + </tr>
43 + </thead>
44 + <tbody>
45 + {b.collectors.map((c) => (
46 + <tr key={c.id}>
47 + <td className="num text-ink">{c.id}</td>
48 + <td className="hidden text-ink-2 sm:table-cell">{c.location}</td>
49 + <td className="num r">{fmt(c.announcements_per_s)}</td>
50 + <td className="num r">{fmt(c.withdrawals_per_s)}</td>
51 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(c.peers)}</td>
52 + <td className="num r text-ink-2">
53 + <Time ts={c.last_message} style="time" />
54 + </td>
55 + <td>
56 + <StatusDot status={c.fresh ? 'online' : 'stale'} />
57 + </td>
58 + </tr>
59 + ))}
60 + </tbody>
61 + </table>
62 + </div>
63 + </Section>
64 + <Section label="Top origins · 1 h" className="min-w-0">
65 + <div className="scroll-x -mx-3 px-3">
66 + <table className="tbl">
67 + <thead>
68 + <tr>
69 + <th>Origin ASN</th>
70 + <th className="r">Announcements</th>
71 + <th className="r">Withdrawals</th>
72 + <th className="r hidden sm:table-cell">Wd. share</th>
73 + </tr>
74 + </thead>
75 + <tbody>
76 + {b.top_origins_1h.map((o) => {
77 + const share = o.announcements + o.withdrawals ? o.withdrawals / (o.announcements + o.withdrawals) : 0;
78 + return (
79 + <tr key={o.asn}>
80 + <td>
81 + <Link href={`/asn/${o.asn}`} className="text-ink hover:text-accent">
82 + <span className="num">AS{o.asn}</span> {o.name}
83 + </Link>
84 + </td>
85 + <td className="num r">{fmtInt(o.announcements)}</td>
86 + <td className="num r" style={{ color: share > 0.3 ? 'var(--p-high)' : undefined }}>
87 + {fmtInt(o.withdrawals)}
88 + </td>
89 + <td className="num r hidden text-ink-2 sm:table-cell">{Math.round(share * 100)} %</td>
90 + </tr>
91 + );
92 + })}
93 + </tbody>
94 + </table>
95 + </div>
96 + </Section>
97 + </div>
98 + </div>
99 + );
100 +}
added apps/web/src/app/(site)/event/[slug]/page.tsx +244 −0
@@ -0,0 +1,244 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { IncidentSeriesChart } from '@/components/incidents/IncidentSeriesChart';
4 +import { TYPE_LABEL } from '@/components/incidents/IncidentRow';
5 +import { Bar, ConfBar, Section, Stat, StatusDot } from '@/components/ui/primitives';
6 +import { apiGet, apiTry } from '@/lib/api';
7 +import { fmt, fmtDuration, fmtInt, fmtRatio } from '@/lib/format';
8 +import { pressureColor } from '@/lib/pressure';
9 +import { Time } from '@/lib/time';
10 +import type { IncidentDetail } from '@/lib/types';
11 +
12 +export const dynamic = 'force-dynamic';
13 +
14 +type Params = Promise<{ slug: string }>;
15 +
16 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
17 + const { slug } = await params;
18 + const i = await apiTry<IncidentDetail>(`/api/v1/incident/${encodeURIComponent(slug)}`);
19 + if (!i) return { title: 'Incident' };
20 + return { title: `${i.title} — ${i.status}, peak ${fmt(i.peak_pressure)}`, description: i.summary, alternates: { canonical: `/event/${i.slug}` }, openGraph: { type: 'article', publishedTime: i.started_at, modifiedTime: i.updated_at } };
21 +}
22 +
23 +function scopeHref(i: IncidentDetail): string | null {
24 + if (i.scope_type === 'region' && i.scope_id) return `/internet/${i.scope_id}`;
25 + if (i.scope_type === 'country' && i.scope_id) return `/country/${i.scope_id.toLowerCase()}`;
26 + if (i.scope_type === 'asn' && i.scope_id) return `/asn/${i.scope_id}`;
27 + if (i.scope_type === 'service' && i.scope_id) return `/service/${i.scope_id}`;
28 + return null;
29 +}
30 +
31 +export default async function EventPage({ params }: { params: Params }) {
32 + const { slug } = await params;
33 + const i = await apiGet<IncidentDetail>(`/api/v1/incident/${encodeURIComponent(slug)}`);
34 + const href = scopeHref(i);
35 + return (
36 + <article className="pb-8">
37 + <header className="grid gap-6 pt-6 pb-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
38 + <div className="min-w-0">
39 + <p className="label flex flex-wrap items-center gap-3">
40 + <span>{TYPE_LABEL[i.type] ?? i.type}</span>
41 + <StatusDot status={i.status} />
42 + <span className="num normal-case tracking-normal text-ink-3">{i.event_id}</span>
43 + </p>
44 + <h1 className="mt-1 text-[26px] font-medium leading-tight tracking-tight sm:text-[32px]">{i.title}</h1>
45 + <p className="mt-2 max-w-[760px] text-[14px] text-ink-2">{i.summary}</p>
46 + <p className="num mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[12px] text-ink-2">
47 + <span>
48 + scope{' '}
49 + {href ? (
50 + <Link href={href} className="text-ink hover:text-accent">
51 + {i.scope_label}
52 + </Link>
53 + ) : (
54 + <span className="text-ink">{i.scope_label}</span>
55 + )}
56 + </span>
57 + <span>
58 + started <Time ts={i.started_at} className="text-ink" />
59 + </span>
60 + <span>
61 + {i.ended_at ? (
62 + <>
63 + ended <Time ts={i.ended_at} className="text-ink" />
64 + </>
65 + ) : (
66 + <>
67 + updated <Time ts={i.updated_at} className="text-ink" />
68 + </>
69 + )}
70 + </span>
71 + <span>
72 + duration <span className="text-ink">{fmtDuration(i.duration_s)}</span>
73 + </span>
74 + </p>
75 + </div>
76 + <div className="flex flex-wrap items-end gap-6">
77 + <div>
78 + <div className="label">current</div>
79 + <div className="num text-[56px] leading-none tracking-[-0.03em]" style={{ color: pressureColor(i.current_pressure) }}>
80 + {fmt(i.current_pressure)}
81 + </div>
82 + </div>
83 + <div>
84 + <div className="label">peak</div>
85 + <div className="num text-[28px] leading-none" style={{ color: pressureColor(i.peak_pressure) }}>
86 + {fmt(i.peak_pressure)}
87 + </div>
88 + </div>
89 + <div className="pb-1">
90 + <div className="label mb-1">confidence</div>
91 + <ConfBar value={i.confidence} />
92 + </div>
93 + </div>
94 + </header>
95 +
96 + <Section label="Series" right={<span>incident scope vs global pressure · from 30 min before detection</span>}>
97 + <IncidentSeriesChart series={i.series} timeline={i.timeline} />
98 + </Section>
99 +
100 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
101 + <Section label="Timeline" className="min-w-0">
102 + <ol className="relative ml-2 border-l border-line">
103 + {i.timeline.map((t, idx) => (
104 + <li key={idx} className="relative mb-4 pl-5 last:mb-0">
105 + <span className="absolute -left-[5px] top-1.5 size-2 rounded-full border border-bg" style={{ background: pressureColor(t.pressure) }} aria-hidden="true" />
106 + <div className="flex flex-wrap items-baseline gap-x-3">
107 + <StatusDot status={t.status} />
108 + <Time ts={t.ts} className="num text-[11.5px] text-ink-2" />
109 + <span className="num text-[12px]" style={{ color: pressureColor(t.pressure) }}>
110 + {fmt(t.pressure)}
111 + </span>
112 + </div>
113 + <p className="mt-0.5 text-[12.5px] text-ink-2">{t.note}</p>
114 + </li>
115 + ))}
116 + </ol>
117 + </Section>
118 + <Section label="Hypotheses" right={<span>never overstated · confidence-weighted</span>} className="min-w-0">
119 + {i.hypotheses.length ? (
120 + <ul className="space-y-4">
121 + {i.hypotheses.map((h, idx) => (
122 + <li key={idx}>
123 + <div className="flex items-baseline justify-between gap-4">
124 + <p className="text-[14px] text-ink">{h.text}</p>
125 + <span className="num shrink-0 text-[12px] text-ink-2">{Math.round(h.confidence * 100)} %</span>
126 + </div>
127 + <Bar value={h.confidence * 100} color="var(--accent)" className="mt-1.5" />
128 + <ul className="mt-2 space-y-0.5 text-[12.5px] text-ink-2">
129 + {h.evidence.map((e, k) => (
130 + <li key={k}>· {e}</li>
131 + ))}
132 + </ul>
133 + </li>
134 + ))}
135 + </ul>
136 + ) : (
137 + <p className="text-[12.5px] text-ink-3">No causal hypothesis reached the minimum confidence.</p>
138 + )}
139 + </Section>
140 + </div>
141 +
142 + <Section label="Evidence" right={<span>signal · scope · current vs baseline · robust z</span>}>
143 + <div className="scroll-x -mx-3 px-3">
144 + <table className="tbl">
145 + <thead>
146 + <tr>
147 + <th>Signal</th>
148 + <th className="hidden sm:table-cell">Scope</th>
149 + <th className="r">Current</th>
150 + <th className="r">Baseline</th>
151 + <th className="r">z</th>
152 + <th className="r hidden sm:table-cell">Samples</th>
153 + <th className="r hidden md:table-cell">At</th>
154 + </tr>
155 + </thead>
156 + <tbody>
157 + {i.evidence.map((e, idx) => (
158 + <tr key={idx}>
159 + <td>
160 + <span className="text-ink">{e.label}</span> <span className="num text-[10.5px] text-ink-3">{e.signal_id}</span>
161 + </td>
162 + <td className="hidden text-ink-2 sm:table-cell">{e.scope_id ? `${e.scope_type}:${e.scope_id}` : e.scope_type}</td>
163 + <td className="num r">{fmt(e.current, Math.abs(e.current) < 1 ? 3 : 1)}</td>
164 + <td className="num r text-ink-2">{fmt(e.baseline, Math.abs(e.baseline) < 1 ? 3 : 1)}</td>
165 + <td className="num r" style={{ color: Math.abs(e.robust_z) >= 3 ? 'var(--p-high)' : 'var(--ink)' }}>
166 + {fmt(e.robust_z)}
167 + </td>
168 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(e.samples)}</td>
169 + <td className="num r hidden text-ink-2 md:table-cell">
170 + <Time ts={e.ts} style="time" />
171 + </td>
172 + </tr>
173 + ))}
174 + </tbody>
175 + </table>
176 + </div>
177 + {i.bgp && (
178 + <div className="mt-4 grid grid-cols-3 gap-4 sm:max-w-[480px]">
179 + <Stat label="BGP withdrawals" value={fmtRatio(i.bgp.withdrawals_ratio)} sub="vs baseline" />
180 + <Stat label="BGP announcements" value={fmtRatio(i.bgp.announcements_ratio)} sub="vs baseline" />
181 + <Stat label="origin changes" value={fmtInt(i.bgp.origin_changes)} />
182 + </div>
183 + )}
184 + </Section>
185 +
186 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
187 + <Section label={`Affected probes · ${fmtInt(i.affected_probes)}`} className="min-w-0">
188 + <ul className="divide-y divide-line text-[12.5px]">
189 + {i.probes.map((p) => (
190 + <li key={p.probe_id} className="flex flex-wrap gap-x-3 py-1.5">
191 + <span className="num w-24 text-ink">{p.probe_id}</span>
192 + <Link href={`/internet/${p.region}`} className="w-24 text-ink-2 hover:text-accent">
193 + {p.region}
194 + </Link>
195 + <span className="flex-1 text-ink-2">{p.observation}</span>
196 + </li>
197 + ))}
198 + </ul>
199 + </Section>
200 + <Section label={`Affected targets · ${fmtInt(i.affected_targets)}`} className="min-w-0">
201 + <ul className="divide-y divide-line text-[12.5px]">
202 + {i.targets.map((t) => (
203 + <li key={t.target_id} className="flex flex-wrap gap-x-3 py-1.5">
204 + <Link href={`/service/${t.service_id}`} className="w-40 truncate text-ink hover:text-accent">
205 + {t.name}
206 + </Link>
207 + <span className="flex-1 text-ink-2">{t.observation}</span>
208 + </li>
209 + ))}
210 + </ul>
211 + <div className="mt-3 flex flex-wrap gap-1.5">
212 + {i.affected_asns.map((a) => (
213 + <Link key={a} href={`/asn/${a}`} className="num rounded-[3px] border border-line px-1.5 py-0.5 text-[11px] text-ink hover:border-line-2">
214 + AS{a}
215 + </Link>
216 + ))}
217 + {i.affected_services.map((s) => (
218 + <Link key={s} href={`/service/${s}`} className="rounded-[3px] border border-line px-1.5 py-0.5 text-[11px] text-ink hover:border-line-2">
219 + {s}
220 + </Link>
221 + ))}
222 + </div>
223 + </Section>
224 + </div>
225 +
226 + <Section label="Annotations">
227 + {i.annotations.length ? (
228 + <ul className="divide-y divide-line text-[13px]">
229 + {i.annotations.map((a, idx) => (
230 + <li key={idx} className="py-2">
231 + <p className="text-ink">{a.text}</p>
232 + <p className="num mt-0.5 text-[11px] text-ink-3">
233 + {a.author} · <Time ts={a.ts} />
234 + </p>
235 + </li>
236 + ))}
237 + </ul>
238 + ) : (
239 + <p className="text-[12.5px] text-ink-3">No human annotation yet.</p>
240 + )}
241 + </Section>
242 + </article>
243 + );
244 +}
added apps/web/src/app/(site)/history/[year]/[month]/page.tsx +84 −0
@@ -0,0 +1,84 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { DayStrip, LargestGrid, TopEvents, TopLists } from '@/components/history/HistoryViews';
5 +import { Section, Stat } from '@/components/ui/primitives';
6 +import { apiGet } from '@/lib/api';
7 +import { MONTH_NAMES, fmt, fmtInt } from '@/lib/format';
8 +import type { HistorySummary } from '@/lib/types';
9 +
10 +export const dynamic = 'force-dynamic';
11 +
12 +type Params = Promise<{ year: string; month: string }>;
13 +
14 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
15 + const { year, month } = await params;
16 + const name = MONTH_NAMES[Number(month) - 1] ?? month;
17 + return { title: `History ${name} ${year}`, description: `Daily Global Internet Pressure for ${name} ${year} with the largest events and most affected networks.` };
18 +}
19 +
20 +export default async function HistoryMonthPage({ params }: { params: Params }) {
21 + const { year, month } = await params;
22 + const y = Number(year);
23 + const m = Number(month);
24 + if (!/^\d{4}$/.test(year) || !(m >= 1 && m <= 12)) notFound();
25 + const s = await apiGet<HistorySummary>(`/api/v1/history/summary?year=${y}&month=${m}`);
26 + const days = s.days ?? [];
27 + const max = days.length ? Math.max(...days.map((d) => d.max)) : null;
28 + const min = days.length ? Math.min(...days.map((d) => d.min)) : null;
29 + const avg = days.length ? days.reduce((a, d) => a + d.avg, 0) / days.length : null;
30 + const events = days.reduce((a, d) => a + d.events, 0);
31 + const idx = s.available_months.indexOf(`${year}-${String(m).padStart(2, '0')}`);
32 + const prev = idx > 0 ? s.available_months[idx - 1] : null;
33 + const next = idx >= 0 && idx < s.available_months.length - 1 ? s.available_months[idx + 1] : null;
34 + const toHref = (ym: string) => `/history/${ym.split('-')[0]}/${Number(ym.split('-')[1])}`;
35 + return (
36 + <div className="pb-8">
37 + <header className="pt-6 pb-4">
38 + <p className="label">
39 + <Link href="/history" className="hover:text-ink">
40 + History
41 + </Link>{' '}
42 + ·{' '}
43 + <Link href={`/history/${year}`} className="hover:text-ink">
44 + {year}
45 + </Link>
46 + </p>
47 + <div className="mt-1 flex flex-wrap items-baseline gap-4">
48 + <h1 className="text-[26px] font-medium tracking-tight sm:text-[32px]">
49 + {MONTH_NAMES[m - 1]} <span className="num">{year}</span>
50 + </h1>
51 + <nav className="num flex gap-2 text-[12px]" aria-label="Adjacent months">
52 + {prev && (
53 + <Link href={toHref(prev)} className="text-ink-2 hover:text-ink">
54 + ← {prev}
55 + </Link>
56 + )}
57 + {next && (
58 + <Link href={toHref(next)} className="text-ink-2 hover:text-ink">
59 + {next} →
60 + </Link>
61 + )}
62 + </nav>
63 + </div>
64 + <div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-5">
65 + <Stat label="days observed" value={fmtInt(days.length)} />
66 + <Stat label="max" value={<span style={{ color: max == null ? undefined : `var(--p-${max <= 10 ? 'calm' : max <= 25 ? 'normal' : max <= 40 ? 'elevated' : max <= 55 ? 'stressed' : max <= 70 ? 'high' : max <= 85 ? 'severe' : 'extreme'})` }}>{fmt(max)}</span>} />
67 + <Stat label="avg" value={fmt(avg)} />
68 + <Stat label="min" value={fmt(min)} />
69 + <Stat label="events" value={fmtInt(events)} />
70 + </div>
71 + </header>
72 + <Section label="Calendar" right={<span>daily max pressure</span>}>
73 + <DayStrip days={days} year={y} month={m} />
74 + </Section>
75 + <Section label="Largest events">
76 + <LargestGrid largest={s.largest} />
77 + </Section>
78 + <TopLists s={s} />
79 + <Section label="Events this month">
80 + <TopEvents events={s.top_events.filter((e) => e.started_at.startsWith(`${year}-${String(m).padStart(2, '0')}`))} />
81 + </Section>
82 + </div>
83 + );
84 +}
added apps/web/src/app/(site)/history/[year]/page.tsx +45 −0
@@ -0,0 +1,45 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { notFound } from 'next/navigation';
4 +import { LargestGrid, MonthStrip, TopEvents, TopLists } from '@/components/history/HistoryViews';
5 +import { Section } from '@/components/ui/primitives';
6 +import { apiGet } from '@/lib/api';
7 +import type { HistorySummary } from '@/lib/types';
8 +
9 +export const dynamic = 'force-dynamic';
10 +
11 +type Params = Promise<{ year: string }>;
12 +
13 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
14 + const { year } = await params;
15 + return { title: `History ${year}`, description: `Global Internet Pressure in ${year}: months, largest events, most affected networks and regions.` };
16 +}
17 +
18 +export default async function HistoryYearPage({ params }: { params: Params }) {
19 + const { year } = await params;
20 + if (!/^\d{4}$/.test(year)) notFound();
21 + const s = await apiGet<HistorySummary>(`/api/v1/history/summary?year=${year}`);
22 + return (
23 + <div className="pb-8">
24 + <header className="pt-6 pb-4">
25 + <p className="label">
26 + <Link href="/history" className="hover:text-ink">
27 + History
28 + </Link>{' '}
29 + · year
30 + </p>
31 + <h1 className="num mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">{year}</h1>
32 + </header>
33 + <Section label="Months">
34 + <MonthStrip months={s.months ?? []} />
35 + </Section>
36 + <Section label="Largest events">
37 + <LargestGrid largest={s.largest} />
38 + </Section>
39 + <TopLists s={s} />
40 + <Section label="Top events">
41 + <TopEvents events={s.top_events} />
42 + </Section>
43 + </div>
44 + );
45 +}
added apps/web/src/app/(site)/history/page.tsx +42 −0
@@ -0,0 +1,42 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { LargestGrid, MonthStrip, TopEvents, TopLists } from '@/components/history/HistoryViews';
4 +import { Section } from '@/components/ui/primitives';
5 +import { apiGet } from '@/lib/api';
6 +import type { HistorySummary } from '@/lib/types';
7 +
8 +export const dynamic = 'force-dynamic';
9 +export const metadata: Metadata = { title: 'History', description: 'Explore the proprietary history of Global Internet Pressure: months, days, largest events, most affected networks and regions.' };
10 +
11 +export default async function HistoryPage() {
12 + const s = await apiGet<HistorySummary>('/api/v1/history/summary');
13 + return (
14 + <div className="pb-8">
15 + <header className="pt-6 pb-4">
16 + <p className="label">History explorer</p>
17 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">All time</h1>
18 + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Pressure history is retained indefinitely (1-minute for a year, 5-minute for three, hourly forever). Every incident page is kept. Browse by month, then by day.</p>
19 + <nav className="num mt-3 flex flex-wrap gap-2 text-[12px]" aria-label="Available months">
20 + {s.available_months.map((m) => {
21 + const [y, mo] = m.split('-');
22 + return (
23 + <Link key={m} href={`/history/${y}/${Number(mo)}`} className="rounded-[3px] border border-line px-2 py-1 text-ink-2 hover:text-ink">
24 + {m}
25 + </Link>
26 + );
27 + })}
28 + </nav>
29 + </header>
30 + <Section label="Months">
31 + <MonthStrip months={s.months ?? []} />
32 + </Section>
33 + <Section label="Largest events">
34 + <LargestGrid largest={s.largest} />
35 + </Section>
36 + <TopLists s={s} />
37 + <Section label="Top events" right={<Link href="/incidents" className="hover:text-ink">all incidents →</Link>}>
38 + <TopEvents events={s.top_events} />
39 + </Section>
40 + </div>
41 + );
42 +}
added apps/web/src/app/(site)/methodology/page.tsx +169 −0
@@ -0,0 +1,169 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { Bar, LevelLegend, Section } from '@/components/ui/primitives';
4 +import { apiTry } from '@/lib/api';
5 +import { fmt } from '@/lib/format';
6 +import { COMPONENT_LABEL, LEVELS } from '@/lib/pressure';
7 +import type { ComponentId, Methodology } from '@/lib/types';
8 +
9 +export const revalidate = 300;
10 +export const metadata: Metadata = { title: 'Methodology', description: 'How the Global Internet Pressure Index is computed: components, weights, baselines, robust z, saturation, levels, confidence, Pressure Fronts and self-exclusion.' };
11 +
12 +export default async function MethodologyPage() {
13 + const m = await apiTry<Methodology>('/api/v1/methodology');
14 + const weights = m?.weights ?? {};
15 + const levels = m?.levels ?? LEVELS.map((l) => ({ max: l.max, id: l.id, label: l.label }));
16 + const eng = m?.engine ?? {};
17 + return (
18 + <div className="pb-8">
19 + <header className="pt-6 pb-4">
20 + <p className="label">Methodology {m ? <span className="num normal-case tracking-normal text-ink-3">· config v{m.version} · updated {m.updated_at.slice(0, 10)}</span> : <span className="text-warn">· live config unavailable, showing documented defaults</span>}</p>
21 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">How the pressure is computed</h1>
22 + <p className="mt-2 max-w-[780px] text-[14px] leading-relaxed text-ink-2">The Global Internet Pressure Index is a composite observability index between 0 and 100 describing how stressed, unstable, congested, degraded or abnormal the public Internet currently is. It is not scientific truth and it is not an uptime percentage: it is a baseline-relative synthesis of independent telemetry, and every number on this site decomposes into the signals that produced it.</p>
23 + </header>
24 +
25 + <Section label="1 · Data hierarchy" title="Our own measurements first">
26 + <ol className="max-w-[780px] list-decimal space-y-1 pl-5 text-[13.5px] leading-relaxed text-ink-2">
27 + <li>Direct measurements from the InternetPressure Observability Network — our probes (HTTP, TCP, TLS, ICMP, DNS, traceroute) toward a registry of representative targets.</li>
28 + <li>Open public Internet telemetry — BGP messages from RIPE RIS Live and RouteViews collectors.</li>
29 + <li>Public raw feeds and polled public infrastructure information.</li>
30 + <li>External APIs and vendor status pages only as optional corroboration (weight 0.05) — the instrument must remain useful if every commercial API disappears.</li>
31 + </ol>
32 + </Section>
33 +
34 + <Section label="2 · Components & weights" title="Configuration-driven, never hard-coded">
35 + <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
36 + <table className="tbl self-start">
37 + <thead>
38 + <tr>
39 + <th>Component</th>
40 + <th className="r">Weight</th>
41 + <th className="w-[40%]"></th>
42 + </tr>
43 + </thead>
44 + <tbody>
45 + {Object.entries(weights).map(([id, w]) => (
46 + <tr key={id}>
47 + <td className="text-ink">{COMPONENT_LABEL[id as ComponentId] ?? id}</td>
48 + <td className="num r">{fmt(w, 2)}</td>
49 + <td>
50 + <Bar value={w * 100} max={30} color="var(--accent)" />
51 + </td>
52 + </tr>
53 + ))}
54 + <tr>
55 + <td className="text-ink-2">Σ</td>
56 + <td className="num r text-ink-2">{fmt(Object.values(weights).reduce((a, b) => a + b, 0), 2)}</td>
57 + <td></td>
58 + </tr>
59 + </tbody>
60 + </table>
61 + <div className="space-y-3 text-[13.5px] leading-relaxed text-ink-2">
62 + <p>
63 + <span className="text-ink">Routing</span> — BGP announcements and withdrawals per second, churn, origin-ASN changes, visibility loss, path instability and collector disagreement. <span className="text-ink">Latency</span> — ICMP RTT, TCP connect, TLS handshake, HTTP time-to-first-byte, inter-region latency, packet loss and jitter, always relative to baseline (20 → 100 ms matters; 100 ms alone does not). <span className="text-ink">DNS</span> — lookup latency, SERVFAIL and timeouts, NXDOMAIN anomalies, resolver disagreement across local, Google, Cloudflare and Quad9 resolvers. <span className="text-ink">Availability</span> — representative endpoints failing from at least two probe regions (single-probe failures are the probe&apos;s problem, not the Internet&apos;s). <span className="text-ink">HTTP/TLS</span> — 5xx, certificate and handshake failures, resets and timeouts. <span className="text-ink">Path</span> — traceroute fingerprints, hop-count deviation and latency shift on changed paths. <span className="text-ink">Corroboration</span> — incidents publicly declared by major providers.
64 + </p>
65 + {m?.components && (
66 + <details className="rounded-[4px] border border-line p-3">
67 + <summary className="cursor-pointer text-[12px] text-ink">Signal recipe per component (from the live configuration)</summary>
68 + <div className="mt-3 grid gap-3 sm:grid-cols-2">
69 + {Object.entries(m.components).map(([cid, c]) => (
70 + <div key={cid}>
71 + <p className="label">{COMPONENT_LABEL[cid as ComponentId] ?? cid}</p>
72 + <ul className="mt-1 space-y-0.5 text-[12px]">
73 + {c.signals.map((s) => (
74 + <li key={s.id} className="flex justify-between gap-3">
75 + <span>
76 + {s.label} <span className="num text-ink-3">{s.id}</span>
77 + </span>
78 + <span className="num text-ink">{fmt(s.weight, 2)}</span>
79 + </li>
80 + ))}
81 + </ul>
82 + </div>
83 + ))}
84 + </div>
85 + </details>
86 + )}
87 + </div>
88 + </div>
89 + </Section>
90 +
91 + <Section label="3 · Baselines & anomaly" title="Robust z against a rolling, seasonal baseline">
92 + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2">
93 + <p>
94 + For every signal and scope we keep a trailing baseline of <span className="num text-ink">{String(eng.baseline_days ?? 7)} days</span>, restricted to the same hour of day ±1 h once at least <span className="num text-ink">{String(eng.seasonality_min_days ?? 3)}</span> days of history exist (normal daily patterns are not pressure). The most recent <span className="num text-ink">{String(eng.baseline_exclude_seconds ?? 600)} s</span> are excluded so an incident cannot baseline itself. Anomaly strength is the robust z-score
95 + </p>
96 + <p className="num rounded-[4px] border border-line bg-panel px-4 py-3 text-center text-[14px] text-ink">z = (x − median) / MAD, clipped to [{String(eng.z_clip_low ?? -3)}, {String(eng.z_clip_high ?? 8)}]</p>
97 + <p>
98 + A (probe, target) pair is abnormal above z = <span className="num text-ink">{String(eng.z_anomaly ?? 3)}</span>. Signals with fewer than <span className="num text-ink">{String(eng.baseline_min_samples ?? 24)}</span> samples are &ldquo;weak coverage&rdquo; and their weight is damped: no strong conclusions from thin data. Each component sums its weighted signal stresses and saturates into a 0–100 score with <span className="num text-ink">score = 100 × (1 − e^(−{String(eng.saturation_k ?? 0.35)} × stress))</span>, then the index is the weighted sum of component scores. The engine runs every <span className="num text-ink">{String(eng.cycle_seconds ?? 10)} s</span> over a <span className="num text-ink">{String(eng.window_seconds ?? 120)} s</span> window ({String(eng.bgp_window_seconds ?? 60)} s for BGP rates).
99 + </p>
100 + </div>
101 + </Section>
102 +
103 + <Section label="4 · Levels" title="Seven levels, one colour each">
104 + <table className="tbl max-w-[560px]">
105 + <thead>
106 + <tr>
107 + <th>Level</th>
108 + <th className="r">Up to</th>
109 + <th>Meaning</th>
110 + </tr>
111 + </thead>
112 + <tbody>
113 + {levels.map((l) => {
114 + const c = LEVELS.find((x) => x.id === l.id);
115 + return (
116 + <tr key={l.id}>
117 + <td>
118 + <span className="inline-flex items-center gap-2 text-ink">
119 + <span className="size-2 rounded-[1px]" style={{ background: c?.color }} aria-hidden="true" />
120 + {c?.short ?? l.id}
121 + </span>
122 + </td>
123 + <td className="num r">{l.max}</td>
124 + <td className="text-ink-2">{l.label}</td>
125 + </tr>
126 + );
127 + })}
128 + </tbody>
129 + </table>
130 + <div className="mt-3">
131 + <LevelLegend />
132 + </div>
133 + </Section>
134 +
135 + <Section label="5 · Importance, confidence & velocity" title="Not all networks weigh the same">
136 + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2">
137 + <p>Each target and ASN carries a Network Importance Score (1–5) reflecting centrality, prefixes, downstream dependency and known services; aggregates weight importance 5 about five times more than importance 1. Event pressure is anomaly strength × affected scope × network importance × confidence. Confidence combines probe count, geographic diversity, signal agreement, BGP corroboration, magnitude, duration and external corroboration. Alongside the index we publish velocity (points per hour), acceleration (per hour²) and volatility so that &ldquo;high but recovering&rdquo; is distinguishable from &ldquo;moderate but worsening&rdquo;.</p>
138 + </div>
139 + </Section>
140 +
141 + <Section id="fronts" label="6 · Pressure Fronts & incidents" title="Storm systems for the Internet">
142 + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2">
143 + <p>
144 + A Pressure Front is declared when at least <span className="num text-ink">{String(m?.fronts?.min_pairs ?? 3)}</span> source-region → destination-region pairs rise together above z = <span className="num text-ink">{String(m?.fronts?.z_threshold ?? 2.5)}</span> with intensity ≥ <span className="num text-ink">{String(m?.fronts?.min_intensity ?? 35)}</span>; it is drawn on the map as a great-circle arc with a direction. Incidents open when a component or regional score exceeds <span className="num text-ink">{String(m?.events?.detect_threshold ?? 45)}</span> for <span className="num text-ink">{String(m?.events?.confirm_cycles ?? 2)}</span> consecutive cycles (detected → developing), become active after <span className="num text-ink">{String(m?.events?.active_cycles ?? 6)}</span>, recover below <span className="num text-ink">{String(m?.events?.recover_threshold ?? 30)}</span> and resolve after <span className="num text-ink">{String(m?.events?.resolve_after_seconds ?? 600)} s</span> continuously below. Correlation is rule-based first — BGP spike + latency + path change + failures raise confidence together — and hypotheses are always phrased as &ldquo;possible&rdquo;, &ldquo;probable&rdquo; or &ldquo;high-confidence&rdquo;.
145 + </p>
146 + </div>
147 + </Section>
148 +
149 + <Section label="7 · Self-exclusion & no fake real-time" title="Our own failure is never an Internet event">
150 + <div className="max-w-[780px] space-y-3 text-[13.5px] leading-relaxed text-ink-2">
151 + <p>
152 + If fewer than <span className="num text-ink">{String(eng.min_probes_for_scoring ?? 2)}</span> probes are fresh (a probe is fresh if it reported within <span className="num text-ink">{String(eng.probe_fresh_seconds ?? 180)} s</span>), if the BGP feed is older than <span className="num text-ink">{String(eng.bgp_fresh_seconds ?? 120)} s</span>, or if our stores are unhealthy, the engine freezes the index at its last value and flags <span className="num text-ink">internal_status = degraded</span>. The interface then shows a visible &ldquo;Instrument degraded&rdquo; state with the freeze time instead of interpreting a frozen number. A probe whose targets fail at ≥ {String(eng.probe_local_failure_ratio ?? 0.8)} at once is excluded — its own uplink is down, not the Internet.
153 + </p>
154 + <p>Nothing on this site is animated without a measurement behind it: if one update arrives every 10 s, the numbers move every 10 s. We never randomise values or synthesise events, and pages for countries, ASNs, services and incidents exist only where we hold real data.</p>
155 + </div>
156 + </Section>
157 +
158 + <Section label="8 · Ethics" title="Ordinary lightweight client traffic">
159 + <p className="max-w-[780px] text-[13.5px] leading-relaxed text-ink-2">
160 + No scanning, no authentication bypass, no private infrastructure, no exploitation, no excessive traffic, no rate-limit evasion and no personal data. Probes fetch a handful of public endpoints on an adaptive schedule that resembles a normal user, and reveal only an approximate position, provider and ASN. Questions: see the{' '}
161 + <Link href="/api" className="text-accent hover:underline">
162 + API page
163 + </Link>{' '}
164 + or write to contact@spboucher.ai.
165 + </p>
166 + </Section>
167 + </div>
168 + );
169 +}
added apps/web/src/app/(site)/probes/page.tsx +78 −0
@@ -0,0 +1,78 @@
1 +import type { Metadata } from 'next';
2 +import { ProbesTable } from '@/components/detail/Tables';
3 +import { MapIsland } from '@/components/map/MapIsland';
4 +import { Section, Stat } from '@/components/ui/primitives';
5 +import { apiGet, apiTry } from '@/lib/api';
6 +import { fmtInt, fmtPct } from '@/lib/format';
7 +import type { Latency, Probe, Region } from '@/lib/types';
8 +
9 +export const dynamic = 'force-dynamic';
10 +export const metadata: Metadata = { title: 'Probe network', description: 'The InternetPressure Observability Network: our own measurement probes, where they sit, who hosts them, and whether they are fresh.' };
11 +
12 +export default async function ProbesPage() {
13 + const [{ probes }, regions, latency] = await Promise.all([apiGet<{ probes: Probe[] }>('/api/v1/probes'), apiTry<{ regions: Region[] }>('/api/v1/pressure/regions'), apiTry<Latency>('/api/v1/latency')]);
14 + const online = probes.filter((p) => p.status === 'online').length;
15 + const regionsCovered = new Set(probes.map((p) => p.region)).size;
16 + const countries = new Set(probes.map((p) => p.country)).size;
17 + const meas = probes.reduce((a, p) => a + p.measurements_1h, 0);
18 + const uptime = probes.length ? probes.reduce((a, p) => a + p.uptime_24h, 0) / probes.length : null;
19 + const byProbe = new Map((latency?.by_probe ?? []).map((b) => [b.probe_id, b]));
20 + return (
21 + <div className="pb-8">
22 + <header className="pt-6 pb-4">
23 + <p className="label">Observability network</p>
24 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Probes</h1>
25 + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Lightweight Go agents running as ordinary clients from MacLustr (Québec), OVHcloud (Québec, France) and rented hosts abroad. They never scan, never bypass anything and never send more traffic than a normal user would. Positions are approximate on purpose.</p>
26 + <div className="mt-4 grid grid-cols-3 gap-4 sm:grid-cols-6">
27 + <Stat label="online" value={<span style={{ color: online < probes.length ? 'var(--warn)' : undefined }}>{`${online}/${probes.length}`}</span>} />
28 + <Stat label="regions" value={fmtInt(regionsCovered)} />
29 + <Stat label="countries" value={fmtInt(countries)} />
30 + <Stat label="measurements / h" value={fmtInt(meas)} />
31 + <Stat label="avg uptime 24h" value={fmtPct(uptime, 2)} />
32 + <Stat label="capabilities" value="http · dns · ping · tr" />
33 + </div>
34 + </header>
35 + <Section label="Map">
36 + <MapIsland initial={{ regions: regions?.regions ?? null, countries: null, probes, fronts: null, incidents: null, matrix: latency?.matrix ?? null }} initialMode="probes" height="h-[280px] sm:h-[380px]" />
37 + </Section>
38 + <Section label="Network" right={<span>{fmtInt(probes.length)} probes</span>}>
39 + <ProbesTable probes={probes} />
40 + </Section>
41 + {latency && (
42 + <Section label="Per-probe latency view" right={<span>medians over the current window · z vs each probe's own baseline</span>}>
43 + <div className="scroll-x -mx-3 px-3">
44 + <table className="tbl">
45 + <thead>
46 + <tr>
47 + <th>Probe</th>
48 + <th className="r">RTT p50</th>
49 + <th className="r">TTFB p50</th>
50 + <th className="r">Loss</th>
51 + <th className="r">z</th>
52 + </tr>
53 + </thead>
54 + <tbody>
55 + {probes.map((p) => {
56 + const b = byProbe.get(p.probe_id);
57 + return (
58 + <tr key={p.probe_id}>
59 + <td className="num text-ink">{p.probe_id}</td>
60 + <td className="num r">{b ? `${b.rtt_ms_median.toFixed(1)} ms` : '—'}</td>
61 + <td className="num r">{b ? `${b.ttfb_ms_median.toFixed(1)} ms` : '—'}</td>
62 + <td className="num r" style={{ color: b && b.loss_pct >= 1 ? 'var(--p-high)' : undefined }}>
63 + {b ? `${b.loss_pct.toFixed(1)} %` : '—'}
64 + </td>
65 + <td className="num r" style={{ color: b && Math.abs(b.z) >= 3 ? 'var(--p-high)' : undefined }}>
66 + {b ? b.z.toFixed(1) : '—'}
67 + </td>
68 + </tr>
69 + );
70 + })}
71 + </tbody>
72 + </table>
73 + </div>
74 + </Section>
75 + )}
76 + </div>
77 + );
78 +}
modified apps/web/src/app/(site)/service/[slug]/page.tsx +1 −1
@@ -5,12 +5,12 @@ import { ScopePressureChart } from '@/components/detail/ScopeCharts';
5 5 import { TargetsTable } from '@/components/detail/Tables';
6 6 import { IncidentsSection } from '@/components/incidents/IncidentsSection';
7 7 import { ProbeTargetMatrix } from '@/components/service/ProbeTargetMatrix';
8 +import { VendorIndicator } from '@/components/service/VendorIndicator';
8 9 import { Section, Stat } from '@/components/ui/primitives';
9 10 import { apiGet, apiTry } from '@/lib/api';
10 11 import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';
11 12 import { Time } from '@/lib/time';
12 13 import type { ServiceDetail } from '@/lib/types';
13 −import { VendorIndicator } from '../../services/page';
14 14
15 15 export const dynamic = 'force-dynamic';
16 16
modified apps/web/src/app/(site)/services/page.tsx +1 −12
@@ -3,23 +3,12 @@ import Link from 'next/link';
3 3 import { LevelBadge, PNum, Section } from '@/components/ui/primitives';
4 4 import { apiGet } from '@/lib/api';
5 5 import { fmtInt, fmtPct } from '@/lib/format';
6 +import { VendorIndicator } from '@/components/service/VendorIndicator';
6 7 import type { ServiceRow } from '@/lib/types';
7 8
8 9 export const dynamic = 'force-dynamic';
9 10 export const metadata: Metadata = { title: 'Services', description: 'Independent observation of major Internet services versus what their status pages declare.' };
10 11
11 −export function VendorIndicator({ v }: { v: ServiceRow['vendor_status'] }) {
12 − if (!v) return <span className="text-[11px] text-ink-3">no connector</span>;
13 − const color = v.indicator === 'none' ? 'var(--ok)' : v.indicator === 'minor' ? 'var(--warn)' : 'var(--bad)';
14 − return (
15 − <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em]" style={{ color }}>
16 − <span className="size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" />
17 − {v.indicator}
18 − {v.incidents ? <span className="num text-ink-2">({v.incidents})</span> : null}
19 − </span>
20 − );
21 −}
22 −
23 12 export default async function ServicesPage() {
24 13 const { services } = await apiGet<{ services: ServiceRow[] }>('/api/v1/services');
25 14 const sorted = [...services].sort((a, b) => b.pressure - a.pressure);
added apps/web/src/app/(site)/targets/page.tsx +24 −0
@@ -0,0 +1,24 @@
1 +import type { Metadata } from 'next';
2 +import { TargetsRegistry } from '@/components/targets/TargetsRegistry';
3 +import { apiGet } from '@/lib/api';
4 +import { fmtInt } from '@/lib/format';
5 +import type { Target } from '@/lib/types';
6 +
7 +export const dynamic = 'force-dynamic';
8 +export const metadata: Metadata = { title: 'Target registry', description: 'The representative endpoints we measure: cloud, CDN, DNS, developer platforms, social, finance, government, streaming and more.' };
9 +
10 +export default async function TargetsPage({ searchParams }: { searchParams: Promise<{ q?: string; category?: string }> }) {
11 + const sp = await searchParams;
12 + const { targets } = await apiGet<{ targets: Target[] }>('/api/v1/targets');
13 + const categories = [...new Set(targets.map((t) => t.category))].sort();
14 + return (
15 + <div className="pb-8">
16 + <header className="pt-6 pb-4">
17 + <p className="label">Registry</p>
18 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Targets</h1>
19 + <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">{fmtInt(targets.length)} endpoints across {categories.length} categories. Tier 1 is checked every 20 s, tier 2 every 45 s, tier 3 every 3 min; traceroutes every 15 min. Importance (1–5) weights each target in the aggregates.</p>
20 + </header>
21 + <TargetsRegistry targets={targets} categories={categories} initialQuery={sp.q ?? ''} initialCategory={sp.category ?? ''} />
22 + </div>
23 + );
24 +}
added apps/web/src/app/admin/annotations/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Annotations } from '@/components/admin/Annotations';
2 +
3 +export default function Page() {
4 + return <Annotations />;
5 +}
added apps/web/src/app/admin/baselines/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Baselines } from '@/components/admin/Baselines';
2 +
3 +export default function Page() {
4 + return <Baselines />;
5 +}
added apps/web/src/app/admin/boost/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Boost } from '@/components/admin/Boost';
2 +
3 +export default function Page() {
4 + return <Boost />;
5 +}
added apps/web/src/app/admin/config/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Config } from '@/components/admin/Config';
2 +
3 +export default function Page() {
4 + return <Config />;
5 +}
added apps/web/src/app/admin/incidents/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { IncidentsReview } from '@/components/admin/IncidentsReview';
2 +
3 +export default function Page() {
4 + return <IncidentsReview />;
5 +}
added apps/web/src/app/admin/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Overview } from '@/components/admin/Overview';
2 +
3 +export default function Page() {
4 + return <Overview />;
5 +}
added apps/web/src/app/admin/probes/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Probes } from '@/components/admin/Probes';
2 +
3 +export default function Page() {
4 + return <Probes />;
5 +}
added apps/web/src/app/admin/raw/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Raw } from '@/components/admin/Raw';
2 +
3 +export default function Page() {
4 + return <Raw />;
5 +}
added apps/web/src/app/admin/replay/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Replay } from '@/components/admin/Replay';
2 +
3 +export default function Page() {
4 + return <Replay />;
5 +}
added apps/web/src/app/admin/targets/page.tsx +5 −0
@@ -0,0 +1,5 @@
1 +import { Targets } from '@/components/admin/Targets';
2 +
3 +export default function Page() {
4 + return <Targets />;
5 +}
modified apps/web/src/components/admin/AdminShell.tsx +115 −1
@@ -1,2 +1,116 @@
1 1 'use client';
2 −export function AdminShell({ children }: { children: React.ReactNode }) { return <div>{children}</div>; }
2 +
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
6 +import { Logo } from '@/components/chrome/Logo';
7 +import { TimeToggle } from '@/components/chrome/TimeToggle';
8 +import { AdminError, adminFetch, getAdminToken, setAdminToken } from '@/lib/admin-fetch';
9 +
10 +const NAV: [string, string][] = [
11 + ['/admin', 'Overview'],
12 + ['/admin/targets', 'Targets'],
13 + ['/admin/probes', 'Probes'],
14 + ['/admin/config', 'Scoring config'],
15 + ['/admin/baselines', 'Baselines'],
16 + ['/admin/raw', 'Raw explorer'],
17 + ['/admin/incidents', 'Incidents review'],
18 + ['/admin/annotations', 'Annotations'],
19 + ['/admin/replay', 'Replay'],
20 + ['/admin/boost', 'Boost'],
21 +];
22 +
23 +const AuthCtx = createContext<{ authed: boolean; logout: () => void }>({ authed: false, logout: () => {} });
24 +export const useAdminAuth = () => useContext(AuthCtx);
25 +
26 +/** Token gate: the token lives in sessionStorage only and is validated against /api/admin/overview. */
27 +export function AdminShell({ children }: { children: ReactNode }) {
28 + const [state, setState] = useState<'checking' | 'locked' | 'ok'>('checking');
29 + const [input, setInput] = useState('');
30 + const [err, setErr] = useState<string | null>(null);
31 + const path = usePathname();
32 +
33 + const verify = useCallback(async (token: string) => {
34 + setAdminToken(token);
35 + try {
36 + await adminFetch('/overview');
37 + setState('ok');
38 + setErr(null);
39 + } catch (e) {
40 + setAdminToken('');
41 + setState('locked');
42 + setErr(e instanceof AdminError && e.status === 401 ? 'Invalid token.' : 'Admin API unreachable.');
43 + }
44 + }, []);
45 +
46 + useEffect(() => {
47 + const t = getAdminToken();
48 + if (t) void verify(t);
49 + else setState('locked');
50 + }, [verify]);
51 +
52 + const logout = useCallback(() => {
53 + setAdminToken('');
54 + setState('locked');
55 + }, []);
56 +
57 + return (
58 + <AuthCtx.Provider value={{ authed: state === 'ok', logout }}>
59 + <div className="flex min-h-screen flex-col">
60 + <header className="border-b border-line">
61 + <div className="mx-auto flex h-[var(--header-h)] max-w-[1600px] items-center gap-4 px-3 sm:px-5">
62 + <Link href="/admin" className="flex items-center gap-2 text-[14px] font-medium tracking-tight text-ink">
63 + <Logo size={20} color="var(--warn)" />
64 + InternetPressure <span className="label text-warn">admin</span>
65 + </Link>
66 + <div className="ml-auto flex items-center gap-3">
67 + <TimeToggle className="hidden sm:inline-flex" />
68 + <Link href="/" className="hidden text-[11.5px] text-ink-2 hover:text-ink sm:inline">
69 + public site →
70 + </Link>
71 + {state === 'ok' && (
72 + <button type="button" onClick={logout} className="rounded-[3px] border border-line px-2 py-1 text-[11px] text-ink-2 hover:text-ink">
73 + lock
74 + </button>
75 + )}
76 + </div>
77 + </div>
78 + {state === 'ok' && (
79 + <nav aria-label="Admin" className="scroll-x mx-auto flex max-w-[1600px] gap-1 px-3 sm:px-5">
80 + {NAV.map(([href, label]) => {
81 + const active = href === '/admin' ? path === '/admin' : path.startsWith(href);
82 + return (
83 + <Link key={href} href={href} aria-current={active ? 'page' : undefined} className={`whitespace-nowrap px-2 py-2 text-[11.5px] tracking-[0.04em] ${active ? 'text-ink' : 'text-ink-2 hover:text-ink'}`} style={active ? { boxShadow: 'inset 0 -1px 0 var(--warn)' } : undefined}>
84 + {label}
85 + </Link>
86 + );
87 + })}
88 + </nav>
89 + )}
90 + </header>
91 + <main className="mx-auto w-full max-w-[1600px] flex-1 px-3 py-5 sm:px-5">
92 + {state === 'checking' && <p className="text-[12px] text-ink-3">Checking token…</p>}
93 + {state === 'locked' && (
94 + <form
95 + className="mx-auto mt-[12vh] max-w-[380px]"
96 + onSubmit={(e) => {
97 + e.preventDefault();
98 + if (input.trim()) void verify(input.trim());
99 + }}
100 + >
101 + <p className="label">Restricted</p>
102 + <h1 className="mt-1 text-[22px] font-medium tracking-tight">Admin console</h1>
103 + <p className="mt-1 text-[12.5px] text-ink-2">Enter the admin token. It is kept in this tab&apos;s session storage only and sent as X-IP-Admin-Token.</p>
104 + <input type="password" value={input} onChange={(e) => setInput(e.target.value)} autoFocus autoComplete="off" placeholder="token" className="num mt-4 h-10 w-full rounded-[4px] border border-line bg-panel px-3 text-[14px] text-ink placeholder:text-ink-3" aria-label="Admin token" />
105 + {err && <p className="mt-2 text-[12px] text-bad">{err}</p>}
106 + <button type="submit" className="mt-3 h-9 w-full rounded-[4px] bg-warn text-[13px] font-medium text-bg hover:opacity-90">
107 + Unlock
108 + </button>
109 + </form>
110 + )}
111 + {state === 'ok' && children}
112 + </main>
113 + </div>
114 + </AuthCtx.Provider>
115 + );
116 +}
added apps/web/src/components/admin/Annotations.tsx +70 −0
@@ -0,0 +1,70 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { adminFetch } from '@/lib/admin-fetch';
5 +import { Time } from '@/lib/time';
6 +import type { AdminAnnotation } from '@/lib/types';
7 +import { AdminPage, ErrorNote, Field, Panel, Toast, btnPrimary, inputCls, useAdmin } from './shared';
8 +
9 +export function Annotations() {
10 + const { data, err, reload } = useAdmin<{ annotations: AdminAnnotation[] }>('/annotations');
11 + const [form, setForm] = useState({ ts: new Date().toISOString().slice(0, 19) + 'Z', scope_type: 'global', scope_id: '', text: '' });
12 + const [msg, setMsg] = useState<string | null>(null);
13 + const [actErr, setActErr] = useState<string | null>(null);
14 + return (
15 + <AdminPage title="Annotations" desc="Human context attached to a moment and a scope (maintenance windows, confirmed causes, external news). Shown on incident pages and charts.">
16 + <Panel title="New annotation" className="mb-3">
17 + <form
18 + className="grid grid-cols-2 gap-3 md:grid-cols-[180px_130px_160px_minmax(0,1fr)_auto]"
19 + onSubmit={(e) => {
20 + e.preventDefault();
21 + setActErr(null);
22 + adminFetch('/annotations', { method: 'POST', body: { ...form, scope_id: form.scope_id || null } })
23 + .then(() => {
24 + setMsg('Annotation saved');
25 + setForm({ ...form, text: '' });
26 + reload();
27 + })
28 + .catch((er: unknown) => setActErr(String(er)))
29 + .finally(() => setTimeout(() => setMsg(null), 3000));
30 + }}
31 + >
32 + <Field label="ts (UTC ISO)">
33 + <input value={form.ts} onChange={(e) => setForm({ ...form, ts: e.target.value })} className={`${inputCls} num w-full`} required />
34 + </Field>
35 + <Field label="scope_type">
36 + <select value={form.scope_type} onChange={(e) => setForm({ ...form, scope_type: e.target.value })} className={`${inputCls} w-full`}>
37 + {['global', 'region', 'country', 'asn', 'service', 'incident'].map((s) => (
38 + <option key={s}>{s}</option>
39 + ))}
40 + </select>
41 + </Field>
42 + <Field label="scope_id">
43 + <input value={form.scope_id} onChange={(e) => setForm({ ...form, scope_id: e.target.value })} className={`${inputCls} num w-full`} />
44 + </Field>
45 + <Field label="text">
46 + <input value={form.text} onChange={(e) => setForm({ ...form, text: e.target.value })} className={`${inputCls} w-full`} required />
47 + </Field>
48 + <div className="flex items-end">
49 + <button type="submit" className={btnPrimary}>
50 + Add
51 + </button>
52 + </div>
53 + </form>
54 + </Panel>
55 + <ErrorNote err={err ?? actErr} />
56 + <Toast msg={msg} />
57 + <ul className="divide-y divide-line">
58 + {(data?.annotations ?? []).map((a, i) => (
59 + <li key={a.id ?? i} className="py-2 text-[13px]">
60 + <p className="text-ink">{a.text}</p>
61 + <p className="num mt-0.5 text-[11px] text-ink-3">
62 + <Time ts={a.ts} /> · {a.scope_type}
63 + {a.scope_id ? `:${a.scope_id}` : ''} · {a.author ?? 'admin'}
64 + </p>
65 + </li>
66 + ))}
67 + </ul>
68 + </AdminPage>
69 + );
70 +}
added apps/web/src/components/admin/Baselines.tsx +62 −0
@@ -0,0 +1,62 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { SeriesChart } from '@/components/charts/SeriesChart';
5 +import { fmt, fmtInt } from '@/lib/format';
6 +import type { AdminBaselines } from '@/lib/types';
7 +import { AdminPage, ErrorNote, Field, Panel, inputCls, useAdmin } from './shared';
8 +
9 +const SIGNALS = ['ttfb_z', 'tcp_z', 'rtt_z', 'loss', 'dns_fail_rate', 'dns_latency_z', 'resolver_disagreement', 'target_down_corroborated', 'fail_rate_z', 'http_5xx_rate', 'tls_fail_rate', 'reset_timeout_rate', 'route_change_rate', 'hop_count_z', 'path_latency_shift', 'bgp_withdrawals_z', 'bgp_announcements_z', 'bgp_origin_changes_z', 'bgp_collector_disagreement', 'vendor_incidents'];
10 +
11 +export function Baselines() {
12 + const [signal, setSignal] = useState('ttfb_z');
13 + const [scopeType, setScopeType] = useState('global');
14 + const [scopeId, setScopeId] = useState('');
15 + const { data, err, loading } = useAdmin<AdminBaselines>('/baselines', { signal_id: signal, scope_type: scopeType, scope_id: scopeId || undefined });
16 + const pts = data?.points ?? [];
17 + return (
18 + <AdminPage title="Baseline diagnostics" desc="Value vs rolling median ± MAD for one signal and scope. If the median drifts with the value, the baseline window is too short or the exclusion too small.">
19 + <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
20 + <Field label="signal_id">
21 + <select value={signal} onChange={(e) => setSignal(e.target.value)} className={`${inputCls} num w-full`}>
22 + {SIGNALS.map((s) => (
23 + <option key={s}>{s}</option>
24 + ))}
25 + </select>
26 + </Field>
27 + <Field label="scope_type">
28 + <select value={scopeType} onChange={(e) => setScopeType(e.target.value)} className={`${inputCls} w-full`}>
29 + {['global', 'region', 'country', 'asn', 'service', 'probe', 'target'].map((s) => (
30 + <option key={s}>{s}</option>
31 + ))}
32 + </select>
33 + </Field>
34 + <Field label="scope_id">
35 + <input value={scopeId} onChange={(e) => setScopeId(e.target.value)} placeholder="na-east · CA · 13335 · …" className={`${inputCls} num w-full`} />
36 + </Field>
37 + <div className="num flex items-end text-[11px] text-ink-3">{data ? `${fmtInt(data.samples)} samples · ${data.baseline_days} d baseline` : loading ? 'loading…' : ''}</div>
38 + </div>
39 + <ErrorNote err={err} />
40 + {pts.length > 0 && (
41 + <Panel className="mt-3">
42 + <SeriesChart
43 + lines={[
44 + { name: 'value', color: '#E6EDF3', points: pts.map((p) => ({ ts: p.ts, value: p.value })), width: 1.25 },
45 + { name: 'median', color: '#5B8DEF', points: pts.map((p) => ({ ts: p.ts, value: p.median })), dashed: true },
46 + { name: 'median + MAD', color: '#8B98A5', points: pts.map((p) => ({ ts: p.ts, value: p.median + p.mad })), dashed: true, width: 0.8 },
47 + { name: 'median − MAD', color: '#8B98A5', points: pts.map((p) => ({ ts: p.ts, value: p.median - p.mad })), dashed: true, width: 0.8 },
48 + { name: 'robust z', color: '#E76F51', points: pts.map((p) => ({ ts: p.ts, value: p.z })), yAxisIndex: 1 },
49 + ]}
50 + yMax="auto"
51 + bands={false}
52 + height={300}
53 + y2={{ max: 'auto', name: 'z' }}
54 + />
55 + <p className="num mt-2 text-[11px] text-ink-3">
56 + latest: value {fmt(pts.at(-1)!.value)} · median {fmt(pts.at(-1)!.median)} · MAD {fmt(pts.at(-1)!.mad, 2)} · z {fmt(pts.at(-1)!.z)}
57 + </p>
58 + </Panel>
59 + )}
60 + </AdminPage>
61 + );
62 +}
added apps/web/src/components/admin/Boost.tsx +86 −0
@@ -0,0 +1,86 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { adminFetch } from '@/lib/admin-fetch';
5 +import type { AdminTarget } from '@/lib/types';
6 +import { AdminPage, ErrorNote, Field, Panel, Toast, btnPrimary, inputCls, useAdmin } from './shared';
7 +
8 +export function Boost() {
9 + const { data } = useAdmin<{ targets: AdminTarget[] }>('/targets');
10 + const [q, setQ] = useState('');
11 + const [sel, setSel] = useState<Set<string>>(new Set());
12 + const [factor, setFactor] = useState(0.5);
13 + const [seconds, setSeconds] = useState(900);
14 + const [msg, setMsg] = useState<string | null>(null);
15 + const [err, setErr] = useState<string | null>(null);
16 + const [busy, setBusy] = useState(false);
17 + const list = (data?.targets ?? []).filter((t) => !q || t.hostname.includes(q) || t.name.toLowerCase().includes(q.toLowerCase()) || t.service_id.includes(q));
18 + const toggle = (id: string) =>
19 + setSel((s) => {
20 + const n = new Set(s);
21 + if (n.has(id)) n.delete(id);
22 + else n.add(id);
23 + return n;
24 + });
25 + return (
26 + <AdminPage title="Sampling boost" desc="Temporarily multiply the check interval of selected targets by a factor < 1 (0.5 = twice as often) for a limited time. Pushed to probes at their next config refresh; never exceeds the ethical traffic budget.">
27 + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-[minmax(0,1fr)_300px]">
28 + <Panel title="Targets" right={<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter…" className={inputCls} aria-label="Filter" />}>
29 + <div className="flex gap-2 text-[11px]">
30 + <button type="button" className="text-ink-2 hover:text-ink" onClick={() => setSel(new Set(list.map((t) => t.target_id)))}>
31 + select shown ({list.length})
32 + </button>
33 + <button type="button" className="text-ink-2 hover:text-ink" onClick={() => setSel(new Set())}>
34 + clear
35 + </button>
36 + </div>
37 + <ul className="mt-2 max-h-[60vh] overflow-auto divide-y divide-line">
38 + {list.map((t) => (
39 + <li key={t.target_id}>
40 + <label className="flex cursor-pointer items-center gap-3 py-1.5 text-[12.5px]">
41 + <input type="checkbox" checked={sel.has(t.target_id)} onChange={() => toggle(t.target_id)} className="accent-[var(--accent)]" />
42 + <span className="text-ink">{t.name}</span>
43 + <span className="num text-ink-3">{t.hostname}</span>
44 + <span className="num ml-auto text-ink-3">tier {t.tier}</span>
45 + </label>
46 + </li>
47 + ))}
48 + </ul>
49 + </Panel>
50 + <Panel title="Boost">
51 + <Field label="factor (interval multiplier)">
52 + <input type="number" step="0.1" min={0.1} max={1} value={factor} onChange={(e) => setFactor(Number(e.target.value))} className={`${inputCls} num w-full`} />
53 + </Field>
54 + <div className="mt-3">
55 + <Field label="duration (seconds)">
56 + <input type="number" step="60" min={60} max={7200} value={seconds} onChange={(e) => setSeconds(Number(e.target.value))} className={`${inputCls} num w-full`} />
57 + </Field>
58 + </div>
59 + <p className="num mt-3 text-[11px] text-ink-3">
60 + {sel.size} targets · ×{factor} for {Math.round(seconds / 60)} min
61 + </p>
62 + <button
63 + type="button"
64 + className={`${btnPrimary} mt-3 w-full`}
65 + disabled={!sel.size || busy || factor <= 0 || factor > 1}
66 + onClick={() => {
67 + setBusy(true);
68 + setErr(null);
69 + adminFetch('/boost', { method: 'POST', body: { targets: [...sel], factor, seconds } })
70 + .then(() => setMsg(`Boost pushed for ${sel.size} targets`))
71 + .catch((e: unknown) => setErr(String(e)))
72 + .finally(() => {
73 + setBusy(false);
74 + setTimeout(() => setMsg(null), 4000);
75 + });
76 + }}
77 + >
78 + Push boost
79 + </button>
80 + <Toast msg={msg} />
81 + <ErrorNote err={err} />
82 + </Panel>
83 + </div>
84 + </AdminPage>
85 + );
86 +}
added apps/web/src/components/admin/Config.tsx +106 −0
@@ -0,0 +1,106 @@
1 +'use client';
2 +
3 +import { useEffect, useState } from 'react';
4 +import { Bar } from '@/components/ui/primitives';
5 +import { adminFetch } from '@/lib/admin-fetch';
6 +import { fmt } from '@/lib/format';
7 +import { COMPONENT_LABEL } from '@/lib/pressure';
8 +import type { AdminConfig, ComponentId } from '@/lib/types';
9 +import { AdminPage, ErrorNote, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared';
10 +
11 +export function Config() {
12 + const { data, err, reload } = useAdmin<AdminConfig>('/config');
13 + const [cfg, setCfg] = useState<AdminConfig | null>(null);
14 + const [msg, setMsg] = useState<string | null>(null);
15 + const [saveErr, setSaveErr] = useState<string | null>(null);
16 + const [busy, setBusy] = useState(false);
17 + useEffect(() => {
18 + if (data) setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig);
19 + }, [data]);
20 +
21 + if (!cfg) return <AdminPage title="Scoring config">{err ? <ErrorNote err={err} /> : <p className="text-[12px] text-ink-3">Loading…</p>}</AdminPage>;
22 +
23 + const sum = Object.values(cfg.pressure_weights).reduce((a, b) => a + Number(b || 0), 0);
24 + const valid = Math.abs(sum - 1) <= 0.001;
25 + const levelsOk = cfg.levels.every((l, i) => i === 0 || l.max > cfg.levels[i - 1]!.max) && cfg.levels.at(-1)?.max === 100;
26 + const dirty = JSON.stringify(cfg) !== JSON.stringify(data);
27 +
28 + const save = async () => {
29 + setBusy(true);
30 + setSaveErr(null);
31 + try {
32 + await adminFetch('/config', { method: 'PUT', body: cfg });
33 + setMsg('Configuration stored — applied on the next engine cycle.');
34 + reload();
35 + } catch (e) {
36 + setSaveErr(String(e));
37 + } finally {
38 + setBusy(false);
39 + setTimeout(() => setMsg(null), 4000);
40 + }
41 + };
42 +
43 + const setEngine = (k: string, v: string) => setCfg({ ...cfg, engine: { ...cfg.engine, [k]: v === '' ? '' : Number.isNaN(Number(v)) ? v : Number(v) } });
44 +
45 + return (
46 + <AdminPage
47 + title={`Scoring config · v${cfg.version}`}
48 + desc="Weights, levels and engine parameters (pressure.yaml). Stored in Postgres and hot-reloaded by the engine. Weights must sum to 1 ± 0.001."
49 + right={
50 + <div className="flex gap-2">
51 + <button type="button" className={btnCls} disabled={!dirty} onClick={() => setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig)}>
52 + reset
53 + </button>
54 + <button type="button" className={btnPrimary} disabled={!valid || !levelsOk || !dirty || busy} onClick={() => void save()}>
55 + PUT config
56 + </button>
57 + </div>
58 + }
59 + >
60 + <ErrorNote err={err ?? saveErr} />
61 + <Toast msg={msg} />
62 + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-3">
63 + <Panel title="Component weights" right={<span className="num" style={{ color: valid ? 'var(--ok)' : 'var(--bad)' }}>Σ {fmt(sum, 3)} {valid ? '✓' : '≠ 1.000'}</span>}>
64 + <ul className="space-y-2">
65 + {Object.entries(cfg.pressure_weights).map(([id, w]) => (
66 + <li key={id} className="grid grid-cols-[110px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]">
67 + <span className="text-ink">{COMPONENT_LABEL[id as ComponentId] ?? id}</span>
68 + <input type="number" step="0.01" min={0} max={1} value={w} onChange={(e) => setCfg({ ...cfg, pressure_weights: { ...cfg.pressure_weights, [id]: Number(e.target.value) } })} className={`${inputCls} num w-full`} aria-label={`${id} weight`} />
69 + <Bar value={Number(w) * 100} max={40} color="var(--accent)" />
70 + </li>
71 + ))}
72 + </ul>
73 + </Panel>
74 + <Panel title="Levels" right={<span style={{ color: levelsOk ? 'var(--ok)' : 'var(--bad)' }}>{levelsOk ? 'monotonic, ends at 100' : 'must increase and end at 100'}</span>}>
75 + <ul className="space-y-2">
76 + {cfg.levels.map((l, i) => (
77 + <li key={l.id} className="grid grid-cols-[80px_72px_minmax(0,1fr)] items-center gap-3 text-[12.5px]">
78 + <span className="text-ink" style={{ color: `var(--p-${l.id})` }}>
79 + {l.id}
80 + </span>
81 + <input type="number" min={0} max={100} value={l.max} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, max: Number(e.target.value) } : x)) })} className={`${inputCls} num w-full`} aria-label={`${l.id} max`} />
82 + <input value={l.label} onChange={(e) => setCfg({ ...cfg, levels: cfg.levels.map((x, j) => (j === i ? { ...x, label: e.target.value } : x)) })} className={`${inputCls} w-full`} aria-label={`${l.id} label`} />
83 + </li>
84 + ))}
85 + </ul>
86 + </Panel>
87 + <Panel title="Engine parameters">
88 + <ul className="space-y-2">
89 + {Object.entries(cfg.engine).map(([k, v]) => (
90 + <li key={k} className="grid grid-cols-[minmax(0,1fr)_110px] items-center gap-3 text-[12px]">
91 + <span className="num truncate text-ink-2" title={k}>
92 + {k}
93 + </span>
94 + <input value={String(v)} onChange={(e) => setEngine(k, e.target.value)} className={`${inputCls} num w-full`} aria-label={k} />
95 + </li>
96 + ))}
97 + </ul>
98 + </Panel>
99 + </div>
100 + <details className="mt-3">
101 + <summary className="cursor-pointer text-[12px] text-ink-2">Full JSON (events, fronts, scheduler, component signal recipes)</summary>
102 + <pre className="num mt-2 max-h-[420px] overflow-auto rounded-[4px] border border-line bg-panel p-3 text-[11px] text-ink-2">{JSON.stringify(cfg, null, 2)}</pre>
103 + </details>
104 + </AdminPage>
105 + );
106 +}
added apps/web/src/components/admin/IncidentsReview.tsx +82 −0
@@ -0,0 +1,82 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useState } from 'react';
5 +import { PNum, StatusDot } from '@/components/ui/primitives';
6 +import { adminFetch } from '@/lib/admin-fetch';
7 +import { fmtDuration } from '@/lib/format';
8 +import { Time } from '@/lib/time';
9 +import type { AdminIncident } from '@/lib/types';
10 +import { AdminPage, ErrorNote, Toast, btnCls, inputCls, useAdmin } from './shared';
11 +
12 +export function IncidentsReview() {
13 + const [status, setStatus] = useState('all');
14 + const { data, err, reload } = useAdmin<{ incidents: AdminIncident[] }>('/incidents', { status });
15 + const [notes, setNotes] = useState<Record<string, string>>({});
16 + const [msg, setMsg] = useState<string | null>(null);
17 + const [actErr, setActErr] = useState<string | null>(null);
18 +
19 + const review = async (i: AdminIncident, verdict: 'confirmed' | 'dismissed' | 'unreviewed') => {
20 + setActErr(null);
21 + try {
22 + await adminFetch(`/incidents/${encodeURIComponent(i.event_id)}`, { method: 'PATCH', body: { review: verdict, note: notes[i.event_id] ?? i.note ?? '' } });
23 + setMsg(`${i.title}: ${verdict}`);
24 + reload();
25 + } catch (e) {
26 + setActErr(String(e));
27 + } finally {
28 + setTimeout(() => setMsg(null), 3000);
29 + }
30 + };
31 +
32 + return (
33 + <AdminPage
34 + title="Incidents review"
35 + desc="Confirm or dismiss detected incidents and leave a note. Reviews feed the replay/validation dataset; they never alter the public record of what the engine detected."
36 + right={
37 + <select value={status} onChange={(e) => setStatus(e.target.value)} className={inputCls} aria-label="Status filter">
38 + {['all', 'active', 'resolved'].map((s) => (
39 + <option key={s}>{s}</option>
40 + ))}
41 + </select>
42 + }
43 + >
44 + <ErrorNote err={err ?? actErr} />
45 + <Toast msg={msg} />
46 + <ul className="divide-y divide-line">
47 + {(data?.incidents ?? []).map((i) => (
48 + <li key={i.event_id} className="grid gap-3 py-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
49 + <div className="min-w-0">
50 + <div className="flex flex-wrap items-baseline gap-x-3">
51 + <PNum value={i.peak_pressure} className="text-[18px]" />
52 + <Link href={`/event/${i.slug}`} className="text-[13.5px] text-ink hover:text-accent">
53 + {i.title}
54 + </Link>
55 + <StatusDot status={i.status} />
56 + <span className="label" style={{ color: i.review === 'confirmed' ? 'var(--ok)' : i.review === 'dismissed' ? 'var(--bad)' : 'var(--ink-3)' }}>
57 + {i.review ?? 'unreviewed'}
58 + </span>
59 + </div>
60 + <p className="num mt-0.5 text-[11px] text-ink-3">
61 + {i.type} · {i.scope_label} · <Time ts={i.started_at} /> · {fmtDuration(i.duration_s)} · conf {Math.round(i.confidence * 100)} %
62 + </p>
63 + <p className="mt-1 text-[12px] text-ink-2">{i.summary}</p>
64 + </div>
65 + <textarea value={notes[i.event_id] ?? i.note ?? ''} onChange={(e) => setNotes({ ...notes, [i.event_id]: e.target.value })} placeholder="review note…" rows={2} className={`${inputCls} h-auto w-full py-1.5`} aria-label={`Note for ${i.title}`} />
66 + <div className="flex gap-2 lg:flex-col">
67 + <button type="button" className={`${btnCls} border-ok/60 text-ok`} onClick={() => void review(i, 'confirmed')}>
68 + confirm
69 + </button>
70 + <button type="button" className={`${btnCls} border-bad/60 text-bad`} onClick={() => void review(i, 'dismissed')}>
71 + dismiss
72 + </button>
73 + <button type="button" className={btnCls} onClick={() => void review(i, 'unreviewed')}>
74 + save note
75 + </button>
76 + </div>
77 + </li>
78 + ))}
79 + </ul>
80 + </AdminPage>
81 + );
82 +}
added apps/web/src/components/admin/Overview.tsx +179 −0
@@ -0,0 +1,179 @@
1 +'use client';
2 +
3 +import { Stat, StatusDot } from '@/components/ui/primitives';
4 +import { fmt, fmtBytes, fmtInt, fmtPct } from '@/lib/format';
5 +import { Time } from '@/lib/time';
6 +import type { AdminOverview } from '@/lib/types';
7 +import { AdminPage, ErrorNote, Ok, Panel, useAdmin } from './shared';
8 +
9 +export function Overview() {
10 + const { data: o, err, loading, reload } = useAdmin<AdminOverview>('/overview');
11 + return (
12 + <AdminPage
13 + title="Pipeline health"
14 + desc="Probes, ingest, stores, BGP collectors, engine timings and corroboration connectors. Everything here is about US, not about the Internet."
15 + right={
16 + <button type="button" onClick={reload} className="h-8 rounded-[4px] border border-line px-3 text-[12px] text-ink-2 hover:text-ink" disabled={loading}>
17 + {loading ? 'refreshing…' : 'refresh'}
18 + </button>
19 + }
20 + >
21 + <ErrorNote err={err} />
22 + {o && (
23 + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-2">
24 + <Panel title="Engine" right={<StatusDot status={o.engine.internal_status} />} className="lg:col-span-2">
25 + <div className="grid grid-cols-3 gap-4 sm:grid-cols-6">
26 + <Stat label="last run" value={<Time ts={o.engine.last_run} style="time" />} />
27 + <Stat label="cycle p50" value={`${fmtInt(o.engine.cycle_ms_p50)} ms`} />
28 + <Stat label="cycle max" value={<span style={{ color: o.engine.cycle_ms_max > 5000 ? 'var(--warn)' : undefined }}>{`${fmtInt(o.engine.cycle_ms_max)} ms`}</span>} />
29 + <Stat label="runs 1h" value={fmtInt(o.engine.runs_1h)} />
30 + <Stat label="errors 1h" value={<span style={{ color: o.engine.errors_1h ? 'var(--bad)' : undefined }}>{fmtInt(o.engine.errors_1h)}</span>} />
31 + <Stat label="excluded probes" value={o.engine.excluded_probes.length ? o.engine.excluded_probes.join(', ') : 'none'} />
32 + </div>
33 + </Panel>
34 +
35 + <Panel title="Probes" right={<span>{o.probes.filter((p) => p.status === 'online').length}/{o.probes.length} online</span>} className="lg:col-span-2">
36 + <div className="scroll-x">
37 + <table className="tbl">
38 + <thead>
39 + <tr>
40 + <th>Probe</th>
41 + <th>Status</th>
42 + <th className="r">Uptime 24h</th>
43 + <th className="r">Clock</th>
44 + <th className="r">Missing 1h</th>
45 + <th className="r">Error rate 1h</th>
46 + <th className="r">Buffered</th>
47 + <th className="r">Spool</th>
48 + <th>Version</th>
49 + <th className="r">Last health</th>
50 + </tr>
51 + </thead>
52 + <tbody>
53 + {o.probes.map((p) => (
54 + <tr key={p.probe_id}>
55 + <td className="num text-ink">
56 + {p.probe_id} <span className="text-ink-3">{p.region}</span>
57 + </td>
58 + <td>
59 + <StatusDot status={p.status} />
60 + </td>
61 + <td className="num r" style={{ color: (p.health?.uptime_24h ?? 1) < 0.99 ? 'var(--warn)' : undefined }}>
62 + {fmtPct(p.health?.uptime_24h, 2)}
63 + </td>
64 + <td className="num r" style={{ color: Math.abs(p.health?.clock_offset_ms ?? 0) > 50 ? 'var(--warn)' : undefined }}>
65 + {p.health ? `${p.health.clock_offset_ms > 0 ? '+' : ''}${p.health.clock_offset_ms} ms` : '—'}
66 + </td>
67 + <td className="num r">{fmtPct(p.health?.missing_ratio_1h, 2)}</td>
68 + <td className="num r" style={{ color: (p.health?.error_rate_1h ?? 0) > 0.05 ? 'var(--warn)' : undefined }}>
69 + {fmtPct(p.health?.error_rate_1h, 2)}
70 + </td>
71 + <td className="num r" style={{ color: p.health?.buffered ? 'var(--warn)' : undefined }}>
72 + {fmtInt(p.health?.buffered)}
73 + </td>
74 + <td className="num r text-ink-2">{fmtBytes(p.health?.spool_bytes)}</td>
75 + <td className="num text-ink-2">{p.health?.version ?? p.version}</td>
76 + <td className="num r text-ink-2">
77 + <Time ts={p.health?.last_health ?? p.last_seen} style="time" />
78 + </td>
79 + </tr>
80 + ))}
81 + </tbody>
82 + </table>
83 + </div>
84 + </Panel>
85 +
86 + <Panel title="Ingest">
87 + <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
88 + <Stat label="batches / min" value={fmtInt(o.ingest.batches_per_min)} />
89 + <Stat label="measurements / min" value={fmtInt(o.ingest.measurements_per_min)} />
90 + <Stat label="rejected / min" value={<span style={{ color: o.ingest.rejected_per_min ? 'var(--bad)' : undefined }}>{fmtInt(o.ingest.rejected_per_min)}</span>} />
91 + <Stat label="last batch" value={<Time ts={o.ingest.last_batch} style="time" />} />
92 + </div>
93 + </Panel>
94 +
95 + <Panel title="BGP" right={<Ok ok={o.bgp.fresh} label={o.bgp.fresh ? 'fresh' : 'stale'} />}>
96 + <div className="grid grid-cols-3 gap-4">
97 + <Stat label="messages / s" value={fmt(o.bgp.messages_per_s)} />
98 + <Stat label="collectors fresh" value={`${o.bgp.collectors.filter((c) => c.fresh).length}/${o.bgp.collectors.length}`} />
99 + <Stat label="reconnects 24h" value={fmtInt(o.bgp.reconnects_24h)} />
100 + </div>
101 + <ul className="mt-3 flex flex-wrap gap-1.5">
102 + {o.bgp.collectors.map((c) => (
103 + <li key={c.id} className="num rounded-[3px] border border-line px-1.5 py-0.5 text-[11px]" style={{ color: c.fresh ? 'var(--ink)' : 'var(--warn)' }} title={`${c.location} · ${c.peers} peers`}>
104 + {c.id}
105 + </li>
106 + ))}
107 + </ul>
108 + </Panel>
109 +
110 + <Panel title="Stores" className="lg:col-span-2">
111 + <div className="grid grid-cols-3 gap-4">
112 + <Stat label="ClickHouse" value={<Ok ok={o.stores.clickhouse.ok} />} sub={`${fmt(o.stores.clickhouse.inserts_per_s)} inserts/s`} />
113 + <Stat label="Postgres" value={<Ok ok={o.stores.postgres.ok} />} sub={fmtBytes(o.stores.postgres.size_bytes)} />
114 + <Stat label="Redis" value={<Ok ok={o.stores.redis.ok} />} sub={`${fmtBytes(o.stores.redis.used_memory_bytes)} · ${fmtInt(o.stores.redis.keys)} keys`} />
115 + </div>
116 + <div className="scroll-x mt-3"><table className="tbl">
117 + <thead>
118 + <tr>
119 + <th>ClickHouse table</th>
120 + <th className="r">Rows</th>
121 + <th className="r">Bytes</th>
122 + <th className="r">Oldest</th>
123 + <th className="r">Newest</th>
124 + </tr>
125 + </thead>
126 + <tbody>
127 + {o.stores.clickhouse.tables.map((t) => (
128 + <tr key={t.name}>
129 + <td className="num text-ink">{t.name}</td>
130 + <td className="num r">{fmtInt(t.rows)}</td>
131 + <td className="num r text-ink-2">{fmtBytes(t.bytes)}</td>
132 + <td className="num r text-ink-2">
133 + <Time ts={t.oldest} style="date" />
134 + </td>
135 + <td className="num r text-ink-2">
136 + <Time ts={t.newest} style="time" />
137 + </td>
138 + </tr>
139 + ))}
140 + </tbody>
141 + </table>
142 + </div>
143 + </Panel>
144 +
145 + <Panel title="Corroboration connectors" className="lg:col-span-2">
146 + <div className="scroll-x">
147 + <table className="tbl">
148 + <thead>
149 + <tr>
150 + <th>Connector</th>
151 + <th>State</th>
152 + <th className="r">Last fetch</th>
153 + <th className="r">Incidents declared</th>
154 + </tr>
155 + </thead>
156 + <tbody>
157 + {o.corroboration.map((c) => (
158 + <tr key={c.id}>
159 + <td className="text-ink">
160 + {c.name} <span className="num text-ink-3">{c.id}</span>
161 + </td>
162 + <td>
163 + <Ok ok={c.ok} />
164 + </td>
165 + <td className="num r text-ink-2">
166 + <Time ts={c.last_fetch} style="time" />
167 + </td>
168 + <td className="num r">{fmtInt(c.incidents)}</td>
169 + </tr>
170 + ))}
171 + </tbody>
172 + </table>
173 + </div>
174 + </Panel>
175 + </div>
176 + )}
177 + </AdminPage>
178 + );
179 +}
added apps/web/src/components/admin/Probes.tsx +175 −0
@@ -0,0 +1,175 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { StatusDot } from '@/components/ui/primitives';
5 +import { adminFetch } from '@/lib/admin-fetch';
6 +import { fmtInt, fmtPct } from '@/lib/format';
7 +import { Time } from '@/lib/time';
8 +import type { AdminProbe } from '@/lib/types';
9 +import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared';
10 +
11 +const EMPTY = { probe_id: '', name: '', region: 'na-east', country: 'CA', city: '', provider: '', asn: 0, lat: 0, lon: 0 };
12 +
13 +export function Probes() {
14 + const { data, err, reload } = useAdmin<{ probes: AdminProbe[] }>('/probes');
15 + const [form, setForm] = useState<typeof EMPTY | null>(null);
16 + const [key, setKey] = useState<{ probe_id: string; key: string } | null>(null);
17 + const [msg, setMsg] = useState<string | null>(null);
18 + const [actErr, setActErr] = useState<string | null>(null);
19 + const [busy, setBusy] = useState(false);
20 +
21 + const run = async (fn: () => Promise<unknown>, ok: string) => {
22 + setBusy(true);
23 + setActErr(null);
24 + try {
25 + await fn();
26 + setMsg(ok);
27 + reload();
28 + } catch (e) {
29 + setActErr(String(e));
30 + } finally {
31 + setBusy(false);
32 + setTimeout(() => setMsg(null), 3000);
33 + }
34 + };
35 +
36 + return (
37 + <AdminPage
38 + title="Probes"
39 + desc="Register probes and manage their keys. A key is shown exactly once at creation or rotation — copy it into the agent configuration."
40 + right={
41 + <button type="button" className={btnPrimary} onClick={() => setForm({ ...EMPTY })}>
42 + + probe
43 + </button>
44 + }
45 + >
46 + <ErrorNote err={err ?? actErr} />
47 + <Toast msg={msg} />
48 + {key && (
49 + <Panel title={`Key for ${key.probe_id} — shown once`} className="mb-3 border-warn/60">
50 + <div className="flex flex-wrap items-center gap-2">
51 + <code className="num break-all rounded-[3px] bg-panel-2 px-2 py-1 text-[12px] text-ink">{key.key}</code>
52 + <button
53 + type="button"
54 + className={btnCls}
55 + onClick={() => {
56 + void navigator.clipboard.writeText(key.key);
57 + setMsg('Key copied');
58 + setTimeout(() => setMsg(null), 2000);
59 + }}
60 + >
61 + copy
62 + </button>
63 + <button type="button" className={btnCls} onClick={() => setKey(null)}>
64 + dismiss
65 + </button>
66 + </div>
67 + </Panel>
68 + )}
69 + {form && (
70 + <Panel title="Register probe" className="mb-3">
71 + <form
72 + className="grid grid-cols-2 gap-3 md:grid-cols-5"
73 + onSubmit={(e) => {
74 + e.preventDefault();
75 + void run(async () => {
76 + const res = await adminFetch<AdminProbe & { key: string }>('/probes', { method: 'POST', body: { ...form, asn: Number(form.asn), lat: Number(form.lat), lon: Number(form.lon) } });
77 + setKey({ probe_id: res.probe_id, key: res.key });
78 + setForm(null);
79 + }, 'Probe registered');
80 + }}
81 + >
82 + {(
83 + [
84 + ['probe_id', 'text'],
85 + ['name', 'text'],
86 + ['region', 'text'],
87 + ['country', 'text'],
88 + ['city', 'text'],
89 + ['provider', 'text'],
90 + ['asn', 'number'],
91 + ['lat', 'number'],
92 + ['lon', 'number'],
93 + ] as [keyof typeof EMPTY, string][]
94 + ).map(([k, type]) => (
95 + <Field key={k} label={k}>
96 + <input required={k !== 'city'} type={type} step="any" value={form[k]} onChange={(e) => setForm({ ...form, [k]: type === 'number' ? Number(e.target.value) : e.target.value })} className={`${inputCls} num w-full`} />
97 + </Field>
98 + ))}
99 + <div className="col-span-2 flex items-end gap-2 md:col-span-1">
100 + <button type="submit" className={btnPrimary} disabled={busy}>
101 + Create
102 + </button>
103 + <button type="button" className={btnCls} onClick={() => setForm(null)}>
104 + Cancel
105 + </button>
106 + </div>
107 + </form>
108 + </Panel>
109 + )}
110 + <div className="scroll-x">
111 + <table className="tbl">
112 + <thead>
113 + <tr>
114 + <th>Probe</th>
115 + <th>Status</th>
116 + <th>Region</th>
117 + <th>Location</th>
118 + <th>Provider</th>
119 + <th className="r">ASN</th>
120 + <th className="r">Meas./h</th>
121 + <th className="r">Uptime</th>
122 + <th>Version</th>
123 + <th className="r">Last seen</th>
124 + <th>Enabled</th>
125 + <th></th>
126 + </tr>
127 + </thead>
128 + <tbody>
129 + {(data?.probes ?? []).map((p) => (
130 + <tr key={p.probe_id} className={p.enabled === false ? 'opacity-50' : ''}>
131 + <td>
132 + <span className="num text-ink">{p.probe_id}</span> <span className="text-ink-2">{p.name}</span>
133 + </td>
134 + <td>
135 + <StatusDot status={p.status} />
136 + </td>
137 + <td className="num text-ink-2">{p.region}</td>
138 + <td className="text-ink-2">
139 + {p.city}, {p.country} <span className="num text-ink-3">{p.lat}, {p.lon}</span>
140 + </td>
141 + <td className="text-ink-2">{p.provider}</td>
142 + <td className="num r">{p.asn}</td>
143 + <td className="num r">{fmtInt(p.measurements_1h)}</td>
144 + <td className="num r">{fmtPct(p.uptime_24h, 1)}</td>
145 + <td className="num text-ink-2">{p.version ?? '—'}</td>
146 + <td className="num r text-ink-2">{p.last_seen ? <Time ts={p.last_seen} style="time" /> : '—'}</td>
147 + <td>
148 + <button type="button" className="text-[11px] uppercase tracking-[0.1em]" style={{ color: p.enabled === false ? 'var(--ink-3)' : 'var(--ok)' }} onClick={() => void run(() => adminFetch(`/probes/${encodeURIComponent(p.probe_id)}`, { method: 'PATCH', body: { enabled: p.enabled === false } }), p.enabled === false ? 'Probe enabled' : 'Probe disabled')}>
149 + {p.enabled === false ? 'disabled' : 'enabled'}
150 + </button>
151 + </td>
152 + <td className="r">
153 + <button
154 + type="button"
155 + className="text-[11px] text-ink-2 hover:text-ink"
156 + disabled={busy}
157 + onClick={() => {
158 + if (confirm(`Rotate the key of ${p.probe_id}? The agent must be reconfigured with the new key.`))
159 + void run(async () => {
160 + const res = await adminFetch<AdminProbe & { key: string }>(`/probes/${encodeURIComponent(p.probe_id)}/rotate-key`, { method: 'POST' });
161 + setKey({ probe_id: p.probe_id, key: res.key });
162 + }, 'Key rotated');
163 + }}
164 + >
165 + rotate key
166 + </button>
167 + </td>
168 + </tr>
169 + ))}
170 + </tbody>
171 + </table>
172 + </div>
173 + </AdminPage>
174 + );
175 +}
added apps/web/src/components/admin/Raw.tsx +66 −0
@@ -0,0 +1,66 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { fmtInt } from '@/lib/format';
5 +import type { AdminRaw } from '@/lib/types';
6 +import { AdminPage, ErrorNote, Field, inputCls, useAdmin } from './shared';
7 +
8 +const TABLES = ['measurements', 'traceroutes', 'bgp_events', 'bgp_stats', 'pressure_history', 'signal_features', 'probe_health'];
9 +
10 +export function Raw() {
11 + const [table, setTable] = useState('measurements');
12 + const [probe, setProbe] = useState('');
13 + const [target, setTarget] = useState('');
14 + const [limit, setLimit] = useState(100);
15 + const { data, err, loading } = useAdmin<AdminRaw>('/raw', { table, probe_id: probe || undefined, target_id: target || undefined, limit });
16 + return (
17 + <AdminPage title="Raw explorer" desc="Latest rows of a ClickHouse table, newest first. Read-only, capped at 200 rows.">
18 + <div className="grid grid-cols-2 gap-3 md:grid-cols-5">
19 + <Field label="table">
20 + <select value={table} onChange={(e) => setTable(e.target.value)} className={`${inputCls} num w-full`}>
21 + {TABLES.map((t) => (
22 + <option key={t}>{t}</option>
23 + ))}
24 + </select>
25 + </Field>
26 + <Field label="probe_id">
27 + <input value={probe} onChange={(e) => setProbe(e.target.value)} className={`${inputCls} num w-full`} />
28 + </Field>
29 + <Field label="target_id">
30 + <input value={target} onChange={(e) => setTarget(e.target.value)} className={`${inputCls} num w-full`} />
31 + </Field>
32 + <Field label="limit">
33 + <input type="number" min={1} max={200} value={limit} onChange={(e) => setLimit(Math.min(200, Math.max(1, Number(e.target.value) || 1)))} className={`${inputCls} num w-full`} />
34 + </Field>
35 + <div className="num flex items-end text-[11px] text-ink-3">{loading ? 'loading…' : data ? `${fmtInt(data.rows.length)} rows` : ''}</div>
36 + </div>
37 + <ErrorNote err={err} />
38 + {data && (
39 + <div className="scroll-x mt-3 max-h-[70vh] overflow-auto rounded-[4px] border border-line">
40 + <table className="tbl text-[11.5px]">
41 + <thead className="sticky top-0 bg-bg">
42 + <tr>
43 + {data.columns.map((c) => (
44 + <th key={c} className="num">
45 + {c}
46 + </th>
47 + ))}
48 + </tr>
49 + </thead>
50 + <tbody>
51 + {data.rows.map((r, i) => (
52 + <tr key={i}>
53 + {r.map((v, j) => (
54 + <td key={j} className="num max-w-[280px] truncate" title={String(v)}>
55 + {v === null || v === undefined ? <span className="text-ink-3">∅</span> : typeof v === 'boolean' ? (v ? 'true' : 'false') : String(v)}
56 + </td>
57 + ))}
58 + </tr>
59 + ))}
60 + </tbody>
61 + </table>
62 + </div>
63 + )}
64 + </AdminPage>
65 + );
66 +}
added apps/web/src/components/admin/Replay.tsx +79 −0
@@ -0,0 +1,79 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { SeriesChart } from '@/components/charts/SeriesChart';
5 +import { adminFetch } from '@/lib/admin-fetch';
6 +import { fmt } from '@/lib/format';
7 +import { COMPONENT_LABEL, COMPONENT_ORDER } from '@/lib/pressure';
8 +import type { AdminConfig, AdminReplay } from '@/lib/types';
9 +import { AdminPage, ErrorNote, Field, Panel, btnPrimary, inputCls, useAdmin } from './shared';
10 +
11 +export function Replay() {
12 + const { data: cfg } = useAdmin<AdminConfig>('/config');
13 + const [from, setFrom] = useState(new Date(Date.now() - 86400_000).toISOString().slice(0, 16));
14 + const [to, setTo] = useState(new Date().toISOString().slice(0, 16));
15 + const [weights, setWeights] = useState<Record<string, number> | null>(null);
16 + const [res, setRes] = useState<AdminReplay | null>(null);
17 + const [err, setErr] = useState<string | null>(null);
18 + const [busy, setBusy] = useState(false);
19 + const w = weights ?? cfg?.pressure_weights ?? Object.fromEntries(COMPONENT_ORDER.map((c) => [c, 0]));
20 + const sum = Object.values(w).reduce((a, b) => a + Number(b || 0), 0);
21 + const valid = Math.abs(sum - 1) <= 0.001;
22 +
23 + const run = async () => {
24 + setBusy(true);
25 + setErr(null);
26 + try {
27 + setRes(await adminFetch<AdminReplay>('/replay', { method: 'POST', body: { from: new Date(from + 'Z').toISOString(), to: new Date(to + 'Z').toISOString(), weights: w } }));
28 + } catch (e) {
29 + setErr(String(e));
30 + } finally {
31 + setBusy(false);
32 + }
33 + };
34 +
35 + const diffs = res ? res.points.map((p) => p.pressure_replayed - p.pressure_original) : [];
36 + const maxDiff = diffs.length ? Math.max(...diffs.map(Math.abs)) : 0;
37 +
38 + return (
39 + <AdminPage title="Replay" desc="Recompute the index over a past window with alternative weights, from stored signal features. Validates scoring changes against real incidents before applying them.">
40 + <div className="grid grid-cols-[minmax(0,1fr)] gap-3 lg:grid-cols-[320px_minmax(0,1fr)]">
41 + <Panel title="Window & weights" right={<span className="num" style={{ color: valid ? 'var(--ok)' : 'var(--bad)' }}>Σ {fmt(sum, 3)}</span>}>
42 + <div className="grid grid-cols-2 gap-3">
43 + <Field label="from (UTC)">
44 + <input type="datetime-local" value={from} onChange={(e) => setFrom(e.target.value)} className={`${inputCls} num w-full`} />
45 + </Field>
46 + <Field label="to (UTC)">
47 + <input type="datetime-local" value={to} onChange={(e) => setTo(e.target.value)} className={`${inputCls} num w-full`} />
48 + </Field>
49 + </div>
50 + <ul className="mt-3 space-y-2">
51 + {Object.entries(w).map(([id, v]) => (
52 + <li key={id} className="grid grid-cols-[minmax(0,1fr)_80px] items-center gap-3 text-[12.5px]">
53 + <span className="text-ink">{COMPONENT_LABEL[id as keyof typeof COMPONENT_LABEL] ?? id}</span>
54 + <input type="number" step="0.01" min={0} max={1} value={v} onChange={(e) => setWeights({ ...w, [id]: Number(e.target.value) })} className={`${inputCls} num w-full`} aria-label={`${id} weight`} />
55 + </li>
56 + ))}
57 + </ul>
58 + <button type="button" className={`${btnPrimary} mt-3 w-full`} disabled={!valid || busy} onClick={() => void run()}>
59 + {busy ? 'replaying…' : 'Replay'}
60 + </button>
61 + <ErrorNote err={err} />
62 + </Panel>
63 + <Panel title="Original vs replayed" right={res ? <span className="num">step {res.step_seconds} s · max |Δ| {fmt(maxDiff)}</span> : undefined}>
64 + {res ? (
65 + <SeriesChart
66 + lines={[
67 + { name: 'original', color: '#8B98A5', points: res.points.map((p) => ({ ts: p.ts, value: p.pressure_original })), area: true },
68 + { name: 'replayed', color: '#5B8DEF', points: res.points.map((p) => ({ ts: p.ts, value: p.pressure_replayed })), width: 1.5 },
69 + ]}
70 + height={340}
71 + />
72 + ) : (
73 + <p className="py-10 text-center text-[12px] text-ink-3">Choose a window and weights, then replay.</p>
74 + )}
75 + </Panel>
76 + </div>
77 + </AdminPage>
78 + );
79 +}
added apps/web/src/components/admin/Targets.tsx +187 −0
@@ -0,0 +1,187 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { PNum } from '@/components/ui/primitives';
5 +import { adminFetch } from '@/lib/admin-fetch';
6 +import { fmtInt, fmtPct } from '@/lib/format';
7 +import type { AdminTarget } from '@/lib/types';
8 +import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnDanger, btnPrimary, inputCls, useAdmin } from './shared';
9 +
10 +const EMPTY: Partial<AdminTarget> = { target_id: '', name: '', hostname: '', category: 'cloud', provider: '', service_id: '', country: 'US', region: 'na-east', importance: 3, tier: 2 };
11 +const CATEGORIES = ['dns', 'cdn', 'cloud', 'search', 'messaging', 'social', 'finance', 'government', 'news', 'developer', 'ai', 'streaming', 'commerce', 'infrastructure'];
12 +
13 +export function Targets() {
14 + const { data, err, reload } = useAdmin<{ targets: AdminTarget[] }>('/targets');
15 + const [q, setQ] = useState('');
16 + const [editing, setEditing] = useState<Partial<AdminTarget> | null>(null);
17 + const [isNew, setIsNew] = useState(false);
18 + const [msg, setMsg] = useState<string | null>(null);
19 + const [busy, setBusy] = useState(false);
20 + const [actErr, setActErr] = useState<string | null>(null);
21 +
22 + const rows = (data?.targets ?? []).filter((t) => !q || t.hostname.includes(q) || t.name.toLowerCase().includes(q.toLowerCase()) || t.target_id.includes(q));
23 +
24 + const run = async (fn: () => Promise<unknown>, ok: string) => {
25 + setBusy(true);
26 + setActErr(null);
27 + try {
28 + await fn();
29 + setMsg(ok);
30 + setEditing(null);
31 + reload();
32 + } catch (e) {
33 + setActErr(String(e));
34 + } finally {
35 + setBusy(false);
36 + setTimeout(() => setMsg(null), 3000);
37 + }
38 + };
39 +
40 + return (
41 + <AdminPage
42 + title="Targets"
43 + desc="Registry of measured endpoints. Frequencies come from tiers (scheduler); importance weights the aggregates. Changes apply on the next config refresh (≤ 5 min on probes)."
44 + right={
45 + <div className="flex gap-2">
46 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="filter…" className={inputCls} aria-label="Filter" />
47 + <button
48 + type="button"
49 + className={btnPrimary}
50 + onClick={() => {
51 + setEditing({ ...EMPTY });
52 + setIsNew(true);
53 + }}
54 + >
55 + + target
56 + </button>
57 + </div>
58 + }
59 + >
60 + <ErrorNote err={err ?? actErr} />
61 + <Toast msg={msg} />
62 + {editing && (
63 + <Panel title={isNew ? 'Create target' : `Edit ${editing.target_id}`} className="mb-3">
64 + <form
65 + className="grid grid-cols-2 gap-3 md:grid-cols-5"
66 + onSubmit={(e) => {
67 + e.preventDefault();
68 + const body = { ...editing, importance: Number(editing.importance), tier: Number(editing.tier) };
69 + void run(() => (isNew ? adminFetch('/targets', { method: 'POST', body }) : adminFetch(`/targets/${encodeURIComponent(editing.target_id!)}`, { method: 'PATCH', body })), isNew ? 'Target created' : 'Target updated');
70 + }}
71 + >
72 + <Field label="target_id">
73 + <input required disabled={!isNew} value={editing.target_id ?? ''} onChange={(e) => setEditing({ ...editing, target_id: e.target.value })} className={`${inputCls} num w-full`} />
74 + </Field>
75 + <Field label="name">
76 + <input required value={editing.name ?? ''} onChange={(e) => setEditing({ ...editing, name: e.target.value })} className={`${inputCls} w-full`} />
77 + </Field>
78 + <Field label="hostname">
79 + <input required value={editing.hostname ?? ''} onChange={(e) => setEditing({ ...editing, hostname: e.target.value })} className={`${inputCls} num w-full`} />
80 + </Field>
81 + <Field label="category">
82 + <select value={editing.category ?? ''} onChange={(e) => setEditing({ ...editing, category: e.target.value })} className={`${inputCls} w-full`}>
83 + {CATEGORIES.map((c) => (
84 + <option key={c}>{c}</option>
85 + ))}
86 + </select>
87 + </Field>
88 + <Field label="provider">
89 + <input value={editing.provider ?? ''} onChange={(e) => setEditing({ ...editing, provider: e.target.value })} className={`${inputCls} w-full`} />
90 + </Field>
91 + <Field label="service_id">
92 + <input value={editing.service_id ?? ''} onChange={(e) => setEditing({ ...editing, service_id: e.target.value })} className={`${inputCls} num w-full`} />
93 + </Field>
94 + <Field label="country">
95 + <input value={editing.country ?? ''} maxLength={2} onChange={(e) => setEditing({ ...editing, country: e.target.value.toUpperCase() })} className={`${inputCls} num w-full`} />
96 + </Field>
97 + <Field label="region">
98 + <input value={editing.region ?? ''} onChange={(e) => setEditing({ ...editing, region: e.target.value })} className={`${inputCls} num w-full`} />
99 + </Field>
100 + <Field label="importance 1–5">
101 + <input type="number" min={1} max={5} value={editing.importance ?? 3} onChange={(e) => setEditing({ ...editing, importance: Number(e.target.value) })} className={`${inputCls} num w-full`} />
102 + </Field>
103 + <Field label="tier 1–3">
104 + <input type="number" min={1} max={3} value={editing.tier ?? 2} onChange={(e) => setEditing({ ...editing, tier: Number(e.target.value) })} className={`${inputCls} num w-full`} />
105 + </Field>
106 + <div className="col-span-2 flex gap-2 md:col-span-5">
107 + <button type="submit" className={btnPrimary} disabled={busy}>
108 + {isNew ? 'Create' : 'Save'}
109 + </button>
110 + <button type="button" className={btnCls} onClick={() => setEditing(null)}>
111 + Cancel
112 + </button>
113 + </div>
114 + </form>
115 + </Panel>
116 + )}
117 + <div className="scroll-x">
118 + <table className="tbl">
119 + <thead>
120 + <tr>
121 + <th>Target</th>
122 + <th>Hostname</th>
123 + <th>Category</th>
124 + <th>Anchor</th>
125 + <th className="r">Imp.</th>
126 + <th className="r">Tier</th>
127 + <th className="r">Pressure</th>
128 + <th className="r">OK 1h</th>
129 + <th>Enabled</th>
130 + <th></th>
131 + </tr>
132 + </thead>
133 + <tbody>
134 + {rows.map((t) => (
135 + <tr key={t.target_id} className={t.enabled === false ? 'opacity-50' : ''}>
136 + <td>
137 + <span className="text-ink">{t.name}</span> <span className="num text-[10.5px] text-ink-3">{t.target_id}</span>
138 + </td>
139 + <td className="num text-ink-2">{t.hostname}</td>
140 + <td className="text-ink-2">{t.category}</td>
141 + <td className="num text-ink-2">
142 + {t.country} · {t.region}
143 + </td>
144 + <td className="num r">{t.importance}</td>
145 + <td className="num r">{t.tier}</td>
146 + <td className="r">
147 + <PNum value={t.pressure} />
148 + </td>
149 + <td className="num r">{fmtPct(t.ok_ratio_1h, 1)}</td>
150 + <td>
151 + <button type="button" className="text-[11px] uppercase tracking-[0.1em]" style={{ color: t.enabled === false ? 'var(--ink-3)' : 'var(--ok)' }} onClick={() => void run(() => adminFetch(`/targets/${encodeURIComponent(t.target_id)}`, { method: 'PATCH', body: { enabled: t.enabled === false } }), t.enabled === false ? 'Enabled' : 'Disabled')}>
152 + {t.enabled === false ? 'disabled' : 'enabled'}
153 + </button>
154 + </td>
155 + <td className="r">
156 + <div className="flex justify-end gap-1">
157 + <button
158 + type="button"
159 + className="text-[11px] text-ink-2 hover:text-ink"
160 + onClick={() => {
161 + setEditing({ ...t });
162 + setIsNew(false);
163 + }}
164 + >
165 + edit
166 + </button>
167 + <button
168 + type="button"
169 + className="text-[11px] text-bad/80 hover:text-bad"
170 + onClick={() => {
171 + if (confirm(`Delete target ${t.target_id}? History is kept; the target stops being measured.`)) void run(() => adminFetch(`/targets/${encodeURIComponent(t.target_id)}`, { method: 'DELETE' }), 'Target deleted');
172 + }}
173 + >
174 + delete
175 + </button>
176 + </div>
177 + </td>
178 + </tr>
179 + ))}
180 + </tbody>
181 + </table>
182 + </div>
183 + <p className="num mt-2 text-[11px] text-ink-3">{fmtInt(rows.length)} targets</p>
184 + <span className="hidden">{btnDanger}</span>
185 + </AdminPage>
186 + );
187 +}
added apps/web/src/components/admin/shared.tsx +93 −0
@@ -0,0 +1,93 @@
1 +'use client';
2 +
3 +import { useCallback, useEffect, useState, type ReactNode } from 'react';
4 +import { AdminError, adminFetch } from '@/lib/admin-fetch';
5 +
6 +/** Fetch an admin resource with loading/error state and a `reload()` handle. */
7 +export function useAdmin<T>(path: string | null, params?: Record<string, string | number | undefined>) {
8 + const [data, setData] = useState<T | null>(null);
9 + const [err, setErr] = useState<string | null>(null);
10 + const [loading, setLoading] = useState(false);
11 + const key = JSON.stringify(params ?? {});
12 + const reload = useCallback(() => {
13 + if (!path) return;
14 + setLoading(true);
15 + adminFetch<T>(path, { params: JSON.parse(key) as Record<string, string | number | undefined> })
16 + .then((d) => {
17 + setData(d);
18 + setErr(null);
19 + })
20 + .catch((e: unknown) => setErr(e instanceof AdminError ? `${e.status} ${JSON.stringify(e.body ?? '')}` : String(e)))
21 + .finally(() => setLoading(false));
22 + }, [path, key]);
23 + useEffect(() => {
24 + reload();
25 + }, [reload]);
26 + return { data, err, loading, reload };
27 +}
28 +
29 +export function AdminPage({ title, desc, right, children }: { title: string; desc?: string; right?: ReactNode; children: ReactNode }) {
30 + return (
31 + <div>
32 + <header className="mb-4 flex flex-wrap items-end justify-between gap-3">
33 + <div>
34 + <h1 className="text-[22px] font-medium tracking-tight">{title}</h1>
35 + {desc && <p className="mt-0.5 max-w-[760px] text-[12.5px] text-ink-2">{desc}</p>}
36 + </div>
37 + {right}
38 + </header>
39 + {children}
40 + </div>
41 + );
42 +}
43 +
44 +export function Panel({ title, right, children, className = '' }: { title?: string; right?: ReactNode; children: ReactNode; className?: string }) {
45 + return (
46 + <section className={`panel p-3 ${className}`}>
47 + {(title || right) && (
48 + <header className="mb-2 flex items-baseline justify-between gap-3">
49 + {title && <h2 className="label">{title}</h2>}
50 + {right && <div className="text-[11px] text-ink-2">{right}</div>}
51 + </header>
52 + )}
53 + {children}
54 + </section>
55 + );
56 +}
57 +
58 +export function ErrorNote({ err }: { err: string | null }) {
59 + if (!err) return null;
60 + return <p className="my-2 text-[12px] text-bad">Admin API error: {err}</p>;
61 +}
62 +
63 +export function Ok({ ok, label }: { ok: boolean; label?: string }) {
64 + return (
65 + <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.1em]" style={{ color: ok ? 'var(--ok)' : 'var(--bad)' }}>
66 + <span className="size-1.5 rounded-full" style={{ background: ok ? 'var(--ok)' : 'var(--bad)' }} aria-hidden="true" />
67 + {label ?? (ok ? 'ok' : 'down')}
68 + </span>
69 + );
70 +}
71 +
72 +export const inputCls = 'h-8 rounded-[4px] border border-line bg-panel px-2 text-[12.5px] text-ink placeholder:text-ink-3';
73 +export const btnCls = 'h-8 rounded-[4px] border border-line px-3 text-[12px] text-ink hover:border-line-2 disabled:opacity-40';
74 +export const btnPrimary = 'h-8 rounded-[4px] bg-accent px-3 text-[12px] font-medium text-bg hover:opacity-90 disabled:opacity-40';
75 +export const btnDanger = 'h-8 rounded-[4px] border border-bad/60 px-3 text-[12px] text-bad hover:bg-bad/10 disabled:opacity-40';
76 +
77 +export function Field({ label, children }: { label: string; children: ReactNode }) {
78 + return (
79 + <label className="block min-w-0">
80 + <span className="label">{label}</span>
81 + <div className="mt-1">{children}</div>
82 + </label>
83 + );
84 +}
85 +
86 +export function Toast({ msg }: { msg: string | null }) {
87 + if (!msg) return null;
88 + return (
89 + <p role="status" className="my-2 text-[12px] text-ok">
90 + {msg}
91 + </p>
92 + );
93 +}
added apps/web/src/components/bgp/BgpLive.tsx +48 −0
@@ -0,0 +1,48 @@
1 +'use client';
2 +
3 +import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
4 +import { fmt, fmtInt, fmtRatio } from '@/lib/format';
5 +import { useLive } from '@/lib/live';
6 +import { Time } from '@/lib/time';
7 +import type { BgpStats } from '@/lib/types';
8 +
9 +export function BgpLive({ initial }: { initial: BgpStats }) {
10 + const live = useLive((s) => s.bgp);
11 + const updates = useLive((s) => s.updates);
12 + const cur = live ?? initial;
13 + const ratioColor = (r: number) => (r >= 3 ? 'var(--p-high)' : r >= 1.5 ? 'var(--p-elevated)' : 'var(--ink)');
14 + return (
15 + <div>
16 + <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-3 lg:grid-cols-6">
17 + {[
18 + ['updates / s', cur.updates_per_s, 1, undefined],
19 + ['announcements / s', cur.announcements_per_s, 1, `baseline ${fmt(initial.baseline.announcements_per_s, 0)}`],
20 + ['withdrawals / s', cur.withdrawals_per_s, 1, `baseline ${fmt(initial.baseline.withdrawals_per_s, 1)}`],
21 + ['unique prefixes / min', initial.unique_prefixes_1m, 0, undefined],
22 + ['unique origins / min', initial.unique_origins_1m, 0, undefined],
23 + ['origin changes / min', initial.origin_changes_1m, 0, undefined],
24 + ].map(([label, v, d, sub]) => (
25 + <div key={label as string} className="bg-panel px-3 py-2.5">
26 + <dt className="label truncate">{label as string}</dt>
27 + <dd className="num mt-0.5 text-[22px] leading-none text-ink">
28 + <AnimatedNumber value={v as number} digits={d as number} duration={updates ? 500 : 0} />
29 + </dd>
30 + {sub ? <dd className="num mt-0.5 text-[10.5px] text-ink-3">{sub as string}</dd> : null}
31 + </div>
32 + ))}
33 + </dl>
34 + <div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-1 text-[12.5px] text-ink-2">
35 + <span>
36 + announcements vs baseline <span className="num" style={{ color: ratioColor(cur.ratio.announcements) }}>{fmtRatio(cur.ratio.announcements)}</span>
37 + </span>
38 + <span>
39 + withdrawals vs baseline <span className="num" style={{ color: ratioColor(cur.ratio.withdrawals) }}>{fmtRatio(cur.ratio.withdrawals)}</span>
40 + </span>
41 + <span className={cur.fresh ? 'text-ok' : 'text-warn'}>{cur.fresh ? 'feed fresh' : 'feed stale — routing component frozen'}</span>
42 + <span className="num text-ink-3">
43 + {fmtInt(initial.peers)} peers · <Time ts={cur.ts} style="time" />
44 + </span>
45 + </div>
46 + </div>
47 + );
48 +}
added apps/web/src/components/bgp/BgpSeriesChart.tsx +18 −0
@@ -0,0 +1,18 @@
1 +'use client';
2 +
3 +import { SeriesChart } from '@/components/charts/SeriesChart';
4 +
5 +export function BgpSeriesChart({ series }: { series: { ts: string; announcements: number; withdrawals: number }[] }) {
6 + return (
7 + <SeriesChart
8 + lines={[
9 + { name: 'Announcements / min', color: '#5B8DEF', points: series.map((p) => ({ ts: p.ts, value: p.announcements })), area: true },
10 + { name: 'Withdrawals / min', color: '#E76F51', points: series.map((p) => ({ ts: p.ts, value: p.withdrawals })), yAxisIndex: 1, width: 1.5 },
11 + ]}
12 + height={240}
13 + yMax="auto"
14 + bands={false}
15 + y2={{ max: 'auto', name: 'withdrawals' }}
16 + />
17 + );
18 +}
added apps/web/src/components/history/HistoryViews.tsx +185 −0
@@ -0,0 +1,185 @@
1 +import Link from 'next/link';
2 +import { IncidentRow, TYPE_LABEL } from '@/components/incidents/IncidentRow';
3 +import { Empty, PNum, Section } from '@/components/ui/primitives';
4 +import { MONTH_NAMES, fmt, fmtInt } from '@/lib/format';
5 +import { pressureColor } from '@/lib/pressure';
6 +import type { HistoryDay, HistoryMonthRow, HistorySummary, Incident } from '@/lib/types';
7 +
8 +/** Calendar heat strip: one cell per day coloured by max pressure. */
9 +export function DayStrip({ days, year, month }: { days: HistoryDay[]; year: number; month: number }) {
10 + const dim = new Date(Date.UTC(year, month, 0)).getUTCDate();
11 + const byDate = new Map(days.map((d) => [d.date, d]));
12 + const firstDow = (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7; // Monday first
13 + return (
14 + <div>
15 + <div className="grid grid-cols-7 gap-1 text-center text-[10px] text-ink-3">
16 + {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => (
17 + <span key={i}>{d}</span>
18 + ))}
19 + </div>
20 + <div className="mt-1 grid grid-cols-7 gap-0.5 sm:gap-1">
21 + {Array.from({ length: firstDow }).map((_, i) => (
22 + <span key={`e${i}`} />
23 + ))}
24 + {Array.from({ length: dim }, (_, i) => i + 1).map((n) => {
25 + const date = `${year}-${String(month).padStart(2, '0')}-${String(n).padStart(2, '0')}`;
26 + const d = byDate.get(date);
27 + return (
28 + <div key={date} className="min-h-[44px] min-w-0 overflow-hidden rounded-[3px] border border-line p-1 sm:p-1.5" style={{ background: d ? pressureColor(d.max) + '33' : 'var(--neutral)' }} title={d ? `${date}: min ${fmt(d.min)} · avg ${fmt(d.avg)} · max ${fmt(d.max)} · ${d.events} events` : `${date}: not observed`}>
29 + <div className="flex items-baseline justify-between">
30 + <span className="num text-[10.5px] text-ink-2">{n}</span>
31 + {d && d.events > 0 && <span className="num text-[9.5px] text-high">{d.events}</span>}
32 + </div>
33 + {d && (
34 + <div className="num text-[13px] leading-tight" style={{ color: pressureColor(d.max) }}>
35 + {fmt(d.max, 0)}
36 + </div>
37 + )}
38 + </div>
39 + );
40 + })}
41 + </div>
42 + <p className="mt-2 text-[10.5px] text-ink-3">Cell number = daily max pressure · red count = events opened that day · grey = not observed (observatory started 2026-09)</p>
43 + </div>
44 + );
45 +}
46 +
47 +export function MonthStrip({ months }: { months: HistoryMonthRow[] }) {
48 + if (!months.length) return <Empty>No month observed yet.</Empty>;
49 + return (
50 + <ul className="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
51 + {months.map((m) => {
52 + const [y, mo] = m.month.split('-');
53 + return (
54 + <li key={m.month}>
55 + <Link href={`/history/${y}/${Number(mo)}`} className="block rounded-[3px] border border-line p-3 hover:border-line-2" style={{ background: pressureColor(m.max) + '22' }}>
56 + <div className="text-[12px] text-ink">
57 + {MONTH_NAMES[Number(mo) - 1]} {y}
58 + </div>
59 + <div className="num mt-1 text-[22px] leading-none" style={{ color: pressureColor(m.max) }}>
60 + {fmt(m.max)}
61 + </div>
62 + <div className="num mt-1 text-[10.5px] text-ink-2">
63 + avg {fmt(m.avg)} · min {fmt(m.min)} · {fmtInt(m.events)} events
64 + </div>
65 + </Link>
66 + </li>
67 + );
68 + })}
69 + </ul>
70 + );
71 +}
72 +
73 +export function LargestGrid({ largest }: { largest: HistorySummary['largest'] }) {
74 + const cats: [keyof HistorySummary['largest'], string][] = [
75 + ['pressure', 'Largest by pressure'],
76 + ['routing', 'Largest routing event'],
77 + ['dns', 'Largest DNS event'],
78 + ['latency', 'Largest latency event'],
79 + ];
80 + return (
81 + <ul className="grid grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-2 lg:grid-cols-4">
82 + {cats.map(([k, label]) => {
83 + const inc = largest[k];
84 + return (
85 + <li key={k} className="bg-panel p-3">
86 + <div className="label">{label}</div>
87 + {inc ? (
88 + <>
89 + <Link href={`/event/${inc.slug}`} className="mt-1 block text-[13px] text-ink hover:text-accent">
90 + {inc.title}
91 + </Link>
92 + <div className="num mt-1 flex items-baseline gap-2 text-[11px] text-ink-2">
93 + <PNum value={inc.peak_pressure} className="text-[18px]" />
94 + <span>{TYPE_LABEL[inc.type]}</span>
95 + </div>
96 + </>
97 + ) : (
98 + <p className="mt-1 text-[12px] text-ink-3">none in range</p>
99 + )}
100 + </li>
101 + );
102 + })}
103 + </ul>
104 + );
105 +}
106 +
107 +export function TopLists({ s }: { s: HistorySummary }) {
108 + return (
109 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
110 + <Section label="Most affected ASNs" className="min-w-0">
111 + {s.top_asns.length ? (
112 + <table className="tbl">
113 + <thead>
114 + <tr>
115 + <th>ASN</th>
116 + <th className="r">Events</th>
117 + <th className="r">Max pressure</th>
118 + </tr>
119 + </thead>
120 + <tbody>
121 + {s.top_asns.map((a) => (
122 + <tr key={a.asn}>
123 + <td>
124 + <Link href={`/asn/${a.asn}`} className="text-ink hover:text-accent">
125 + <span className="num">AS{a.asn}</span> {a.name}
126 + </Link>
127 + </td>
128 + <td className="num r">{fmtInt(a.events)}</td>
129 + <td className="r">
130 + <PNum value={a.max_pressure} />
131 + </td>
132 + </tr>
133 + ))}
134 + </tbody>
135 + </table>
136 + ) : (
137 + <Empty />
138 + )}
139 + </Section>
140 + <Section label="Most affected regions" className="min-w-0">
141 + {s.top_regions.length ? (
142 + <table className="tbl">
143 + <thead>
144 + <tr>
145 + <th>Region</th>
146 + <th className="r">Events</th>
147 + <th className="r">Max</th>
148 + <th className="r">Hours elevated</th>
149 + </tr>
150 + </thead>
151 + <tbody>
152 + {s.top_regions.map((r) => (
153 + <tr key={r.id}>
154 + <td>
155 + <Link href={`/internet/${r.id}`} className="text-ink hover:text-accent">
156 + {r.name}
157 + </Link>
158 + </td>
159 + <td className="num r">{fmtInt(r.events)}</td>
160 + <td className="r">
161 + <PNum value={r.max_pressure} />
162 + </td>
163 + <td className="num r text-ink-2">{fmt(r.hours_elevated)}</td>
164 + </tr>
165 + ))}
166 + </tbody>
167 + </table>
168 + ) : (
169 + <Empty />
170 + )}
171 + </Section>
172 + </div>
173 + );
174 +}
175 +
176 +export function TopEvents({ events }: { events: Incident[] }) {
177 + if (!events.length) return <Empty>No event in this range.</Empty>;
178 + return (
179 + <ul className="divide-y divide-line">
180 + {events.map((e) => (
181 + <IncidentRow key={e.event_id} inc={e} compact />
182 + ))}
183 + </ul>
184 + );
185 +}
added apps/web/src/components/incidents/IncidentSeriesChart.tsx +17 −0
@@ -0,0 +1,17 @@
1 +'use client';
2 +
3 +import { SeriesChart } from '@/components/charts/SeriesChart';
4 +import type { IncidentDetail } from '@/lib/types';
5 +
6 +export function IncidentSeriesChart({ series, timeline }: { series: IncidentDetail['series']; timeline: IncidentDetail['timeline'] }) {
7 + return (
8 + <SeriesChart
9 + lines={[
10 + { name: 'Incident scope', color: '#E76F51', points: series.points.map((p) => ({ ts: p.ts, value: p.pressure })), area: true, width: 1.5 },
11 + { name: 'Global pressure', color: '#8B98A5', points: series.points.map((p) => ({ ts: p.ts, value: p.global_pressure })), dashed: true },
12 + ]}
13 + height={260}
14 + markLines={timeline.map((t) => ({ ts: t.ts, label: t.status }))}
15 + />
16 + );
17 +}
added apps/web/src/components/service/VendorIndicator.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import type { ServiceRow } from '@/lib/types';
2 +
3 +/** Vendor status-page indicator (none/minor/major/critical) or "no connector" when we have no source for that provider. */
4 +export function VendorIndicator({ v }: { v: ServiceRow['vendor_status'] }) {
5 + if (!v) return <span className="text-[11px] text-ink-3">no connector</span>;
6 + const color = v.indicator === 'none' ? 'var(--ok)' : v.indicator === 'minor' ? 'var(--warn)' : 'var(--bad)';
7 + return (
8 + <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em]" style={{ color }}>
9 + <span className="size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" />
10 + {v.indicator}
11 + {v.incidents ? <span className="num text-ink-2">({v.incidents})</span> : null}
12 + </span>
13 + );
14 +}
added apps/web/src/components/targets/TargetsRegistry.tsx +118 −0
@@ -0,0 +1,118 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useMemo, useState } from 'react';
5 +import { PNum } from '@/components/ui/primitives';
6 +import { fmtInt, fmtMs, fmtPct } from '@/lib/format';
7 +import type { Target } from '@/lib/types';
8 +
9 +type SortKey = 'pressure' | 'name' | 'ok_ratio_1h' | 'ttfb_ms_median_1h' | 'importance';
10 +
11 +export function TargetsRegistry({ targets, categories, initialQuery, initialCategory }: { targets: Target[]; categories: string[]; initialQuery: string; initialCategory: string }) {
12 + const [q, setQ] = useState(initialQuery);
13 + const [cat, setCat] = useState(initialCategory);
14 + const [sort, setSort] = useState<SortKey>('pressure');
15 + const [dir, setDir] = useState<1 | -1>(-1);
16 +
17 + const rows = useMemo(() => {
18 + const s = q.trim().toLowerCase();
19 + return targets
20 + .filter((t) => (!cat || t.category === cat) && (!s || t.hostname.toLowerCase().includes(s) || t.name.toLowerCase().includes(s) || t.provider.toLowerCase().includes(s) || t.target_id.includes(s)))
21 + .sort((a, b) => {
22 + const av = a[sort];
23 + const bv = b[sort];
24 + if (typeof av === 'string' && typeof bv === 'string') return av.localeCompare(bv) * dir;
25 + return ((av as number) - (bv as number)) * dir;
26 + });
27 + }, [targets, q, cat, sort, dir]);
28 +
29 + const th = (key: SortKey, label: string, right = false) => (
30 + <th className={right ? 'r' : ''}>
31 + <button
32 + type="button"
33 + onClick={() => {
34 + if (sort === key) setDir((d) => (d === 1 ? -1 : 1));
35 + else {
36 + setSort(key);
37 + setDir(key === 'name' ? 1 : -1);
38 + }
39 + }}
40 + className={`uppercase tracking-[0.1em] hover:text-ink ${sort === key ? 'text-ink' : ''}`}
41 + aria-sort={sort === key ? (dir === 1 ? 'ascending' : 'descending') : 'none'}
42 + >
43 + {label}
44 + {sort === key ? (dir === 1 ? ' ↑' : ' ↓') : ''}
45 + </button>
46 + </th>
47 + );
48 +
49 + return (
50 + <div>
51 + <div className="flex flex-wrap items-center gap-2">
52 + <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="hostname, provider, id…" className="h-8 w-full max-w-[320px] rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink placeholder:text-ink-3" aria-label="Filter targets" />
53 + <div className="scroll-x flex gap-1" role="radiogroup" aria-label="Category">
54 + <button type="button" role="radio" aria-checked={!cat} onClick={() => setCat('')} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${!cat ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}>
55 + all
56 + </button>
57 + {categories.map((c) => (
58 + <button key={c} type="button" role="radio" aria-checked={cat === c} onClick={() => setCat(c)} className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] ${cat === c ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}>
59 + {c}
60 + </button>
61 + ))}
62 + </div>
63 + <span className="num ml-auto text-[11px] text-ink-3">{fmtInt(rows.length)} shown</span>
64 + </div>
65 + <div className="scroll-x -mx-3 mt-3 px-3">
66 + <table className="tbl">
67 + <thead>
68 + <tr>
69 + {th('name', 'Target')}
70 + <th className="hidden md:table-cell">Hostname</th>
71 + <th className="hidden sm:table-cell">Category</th>
72 + <th className="hidden lg:table-cell">Service</th>
73 + <th className="hidden sm:table-cell">Anchor</th>
74 + {th('importance', 'Imp.', true)}
75 + <th className="r hidden md:table-cell">Tier</th>
76 + {th('pressure', 'Pressure', true)}
77 + {th('ok_ratio_1h', 'OK 1h', true)}
78 + {th('ttfb_ms_median_1h', 'TTFB', true)}
79 + </tr>
80 + </thead>
81 + <tbody>
82 + {rows.map((t) => (
83 + <tr key={t.target_id}>
84 + <td>
85 + <span className="text-ink">{t.name}</span>
86 + </td>
87 + <td className="num hidden text-ink-2 md:table-cell">{t.hostname}</td>
88 + <td className="hidden text-ink-2 sm:table-cell">{t.category}</td>
89 + <td className="hidden lg:table-cell">
90 + <Link href={`/service/${t.service_id}`} className="text-ink-2 hover:text-accent">
91 + {t.service_id}
92 + </Link>
93 + </td>
94 + <td className="hidden sm:table-cell">
95 + <Link href={`/country/${t.country.toLowerCase()}`} className="text-ink-2 hover:text-accent">
96 + {t.country}
97 + </Link>{' '}
98 + <Link href={`/internet/${t.region}`} className="text-ink-3 hover:text-accent">
99 + {t.region}
100 + </Link>
101 + </td>
102 + <td className="num r text-ink-2">{t.importance}</td>
103 + <td className="num r hidden text-ink-2 md:table-cell">{t.tier}</td>
104 + <td className="r">
105 + <PNum value={t.pressure} />
106 + </td>
107 + <td className="num r" style={{ color: t.ok_ratio_1h < 0.98 ? 'var(--p-high)' : undefined }}>
108 + {fmtPct(t.ok_ratio_1h, 1)}
109 + </td>
110 + <td className="num r text-ink-2">{fmtMs(t.ttfb_ms_median_1h)}</td>
111 + </tr>
112 + ))}
113 + </tbody>
114 + </table>
115 + </div>
116 + </div>
117 + );
118 +}
added deploy/bin/backup.sh +46 −0
@@ -0,0 +1,46 @@
1 +#!/usr/bin/env bash
2 +# Nightly backup, runs ON BHS64b (installed as a cron by deploy/bin/backup.sh install from the laptop):
3 +# - Postgres: pg_dump (registry, config, events, annotations)
4 +# - ClickHouse: the irreplaceable tables (pressure_history, bgp_stats_10s, signal_features last 7 d) as Native+zstd
5 +# - copies to BHS128:/srv/backups/internetpressure (off-node, same DC), keeps 14 days locally
6 +#
7 +# deploy/bin/backup.sh install (from the laptop) copy this script to the server + cron 04:10 UTC
8 +# deploy/bin/backup.sh run (on the server) perform a backup now
9 +set -euo pipefail
10 +
11 +if [[ "${1:-}" == "install" ]]; then
12 + source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
13 + scp -q "$REPO_ROOT/deploy/bin/backup.sh" "$IP_SERVER:/opt/internetpressure/deploy/bin/backup.sh"
14 + rssh 'chmod +x /opt/internetpressure/deploy/bin/backup.sh; sudo mkdir -p /var/backups/internetpressure && sudo chown $(id -un) /var/backups/internetpressure;
15 + (crontab -l 2>/dev/null | grep -v internetpressure/deploy/bin/backup.sh; echo "10 4 * * * /opt/internetpressure/deploy/bin/backup.sh run >> /var/backups/internetpressure/backup.log 2>&1") | crontab -; crontab -l | grep backup'
16 + ssh -n BHS128 'sudo mkdir -p /srv/backups/internetpressure && sudo chown ubuntu /srv/backups/internetpressure'
17 + ok "backup cron installed"
18 + exit 0
19 +fi
20 +
21 +[[ "${1:-}" == "run" ]] || { echo "usage: backup.sh install|run" >&2; exit 2; }
22 +cd /opt/internetpressure
23 +DEST=/var/backups/internetpressure
24 +STAMP=$(date -u +%Y%m%d)
25 +mkdir -p "$DEST/$STAMP"
26 +COMPOSE="docker compose -f infra/compose.yml --env-file deploy/.env"
27 +CHPW=$(grep '^CLICKHOUSE_PASSWORD=' deploy/.env | cut -d= -f2)
28 +
29 +echo "[$(date -u +%FT%TZ)] postgres"
30 +$COMPOSE exec -T postgres pg_dump -U ip -d ip --no-owner | gzip -6 > "$DEST/$STAMP/postgres.sql.gz"
31 +
32 +for t in pressure_history bgp_stats_10s bgp_origin_1m events_placeholder; do
33 + [[ "$t" == "events_placeholder" ]] && continue
34 + echo "[$(date -u +%FT%TZ)] clickhouse $t"
35 + $COMPOSE exec -T clickhouse clickhouse-client -u ip --password "$CHPW" -d ip \
36 + -q "SELECT * FROM $t FORMAT Native" | zstd -q -3 -o "$DEST/$STAMP/$t.native.zst" -f
37 +done
38 +echo "[$(date -u +%FT%TZ)] clickhouse signal_features (7 d)"
39 +$COMPOSE exec -T clickhouse clickhouse-client -u ip --password "$CHPW" -d ip \
40 + -q "SELECT * FROM signal_features WHERE ts >= now() - INTERVAL 7 DAY FORMAT Native" | zstd -q -3 -o "$DEST/$STAMP/signal_features_7d.native.zst" -f
41 +
42 +du -sh "$DEST/$STAMP"
43 +rsync -a --delete-after "$DEST/" BHS128:/srv/backups/internetpressure/ 2>/dev/null \
44 + || rsync -a "$DEST/" ubuntu@51.161.112.69:/srv/backups/internetpressure/ || echo "off-node copy failed"
45 +find "$DEST" -maxdepth 1 -type d -name '20*' -mtime +14 -exec rm -rf {} +
46 +echo "[$(date -u +%FT%TZ)] done"
47