InternetPressure.io — web frontend
Next 16 (App Router, React 19, TypeScript strict, Tailwind 4) instrument for The real-time pressure gauge for the
Internet. It renders the public API (docs/API.md) server-side, then follows the /api/v1/live SSE stream in small
client islands. Dark-first, tabular numerals, no motion without a real update (spec §66), visible "Instrument degraded"
state when internal_status ≠ ok (spec §57).
Structure
apps/web
├── next.config.ts env loading (../../.env), /api → API_URL rewrite, security + cache headers, standalone output
├── Dockerfile multi-stage node:22-alpine → .next/standalone (port 8351)
├── mock/server.mjs DEV-ONLY API fixtures for every endpoint of docs/API.md, incl. SSE (never shipped)
├── scripts/copy-maplibre-worker.mjs copies MapLibre 6 module worker to public/maplibre (predev/prebuild)
├── qa/screens.mjs Playwright screenshots + overflow/console checks at 1440×900 and 390×844
├── public/logo.svg gauge glyph (also inline in components/chrome/Logo.tsx and src/app/icon.svg)
└── src
├── app
│ ├── layout.tsx html.dark, Geist fonts, TimeProvider (UTC/local)
│ ├── (site)/ public chrome: layout (LiveProvider + header/footer), all public routes
│ ├── admin/ separate layout (token gate, no SSE) + 10 sections
│ ├── opengraph-image.tsx OG image from the live index · robots.ts · sitemap.ts · error.tsx · not-found.tsx
│ └── globals.css design tokens, pressure scale CSS variables, .tbl dense tables
├── components
│ ├── chrome/ Header, NavLinks, Footer, Logo, LiveIndicator, DegradedBanner, TimeToggle, Search (⌘K)
│ ├── gauge/ Gauge (the number), ExplainPanel (explain rows → components → signals)
│ ├── home/ ComponentRows, Fronts, IncidentsList, Ticker, Clock, RegionsTable, ProbeStrip
│ ├── map/ MapIsland (lazy, ssr:false) → WorldMap (MapLibre), ModeSelector, modes
│ ├── charts/ echarts core registration, useEChart, HistoryChart, SeriesChart
│ ├── detail/ ScopeHeader, ComponentGrid, Tables (targets/probes/latency matrix), ScopeCharts
│ ├── incidents/ routes/ service/ history/ bgp/ targets/ admin/
│ └── ui/ primitives (Section, LevelBadge, Delta, Bar, Sparkline, Stat…), AnimatedNumber
└── lib
├── types.ts TypeScript mirror of docs/API.md (public + admin)
├── api.ts server-side fetch (API_URL_INTERNAL, no-store, 404 → notFound)
├── live.tsx one EventSource per page, backoff reconnect, sliced store (useSyncExternalStore)
├── pressure.ts levels, colours, pressureColor(level|value), component labels
├── format.ts numerals, deltas (true minus), durations, formatTime(ts, utc|local)
├── time.tsx UTC/local toggle persisted in localStorage; <Time/> component
├── geo.ts great-circle interpolation for Pressure Front arcs
├── iso-numeric-to-alpha2.ts world-atlas numeric ids → ISO alpha-2
└── admin-fetch.ts X-IP-Admin-Token client (token in sessionStorage)Routes
| Route | Consumes |
|---|---|
/ |
pressure/global, ticker, pressure/regions, pressure/countries, fronts, incidents?status=active, probes, pressure/history?range=24h, latency + live stream |
/internet/[region] |
pressure/region/{id} |
/country/[cc] |
pressure/country/{cc}, latency (matrix rows of its region) |
/asn/[asn], /asns |
pressure/asn/{asn}, asns |
/service/[slug], /services |
service/{slug}, services |
/routes |
routes/pairs, probes, targets, routes?probe&target (client refetch on change) |
/event/[slug], /incidents |
incident/{slug}, incidents?status=… |
/history, /history/[year], /history/[year]/[month] |
history/summary[?year[&month]] |
/probes |
probes, pressure/regions, latency |
/targets |
targets |
/bgp |
bgp/stats + live bgp_stats |
/methodology (static, revalidate 300) |
methodology + prose from SPEC §3–8, 20, 40, 57, 66 |
/api (static) |
none (documentation) |
/admin/* |
/api/admin/* (client-side, token header) |
/opengraph-image, /sitemap.xml, /robots.txt |
pressure/global; countries/regions/asns/services/incidents/history |
All dynamic pages export dynamic = 'force-dynamic' so next build never bakes API data.
Environment variables
| Variable | Default | Used by |
|---|---|---|
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) |
API_URL_INTERNAL |
falls back to API_URL |
server components / OG image / sitemap fetching the API inside the network |
NEXT_PUBLIC_SITE_URL |
https://www.internetpressure.io |
canonical URLs, OG, sitemap, API examples |
A single .env at the repository root is loaded by next.config.ts (like the sibling projects).
Development
pnpm install
node mock/server.mjs # dev-only fixtures on :8352 (MOCK_DEGRADED=1 to see the degraded state; admin token dev-admin-token)
pnpm dev # http://localhost:8351 (predev copies the MapLibre worker to public/maplibre)
pnpm typecheck && pnpm lint
node qa/screens.mjs # screenshots into qa/screens/ (Playwright borrowed from ~/Desktop/uqo-eval/node_modules)The mock is development-only: it is excluded from the Docker image (Dockerfile.dockerignore) and must never be
deployed. The production site only ever talks to apps/api.
Build & Docker
pnpm build # requires the API (or the mock) reachable at API_URL_INTERNAL for the two static pages
pnpm start # next start -p 8351 -H 0.0.0.0
# from the repository root
docker build -f apps/web/Dockerfile -t internetpressure-web .
docker run --rm -p 8351:8351 -e API_URL_INTERNAL=http://api:8352 internetpressure-weboutput: 'standalone' with outputFileTracingRoot at the repo root puts the server at
.next/standalone/apps/web/server.js; the Dockerfile copies .next/static and public next to it.
pnpm ≥ 11.2 enforces a supply-chain minimumReleaseAge policy and rejects lockfile entries published in the last 24 h
(ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION). .npmrc sets minimum-release-age=0 and the Dockerfile exports
npm_config_minimum_release_age=0 so a freshly bumped dependency does not break the image build; remove both once the
dependency set has settled if you want the policy back.
Notes on MapLibre 6
MapLibre GL ≥ 6 is ESM-only and spawns a module worker resolved from import.meta.url. Bundled by Turbopack that URL
points at a chunk, the worker 404s and the map stays black. scripts/copy-maplibre-worker.mjs copies
maplibre-gl-worker.mjs + maplibre-gl-shared.mjs to public/maplibre/ and WorldMap.tsx calls
setWorkerUrl('/maplibre/maplibre-gl-worker.mjs'). Basemap: https://tiles.openfreemap.org/styles/dark (no key) with
an offline fallback style; countries from world-atlas 110m; only observed countries are coloured.
SSE through a proxy
The live stream must be sent with Cache-Control: no-store, no-transform (plus X-Accel-Buffering: no, as in API.md).
Without no-transform, compressing proxies — including the Next dev rewrite — gzip-buffer text/event-stream and the
browser receives nothing until the connection closes. The mock does this; apps/api must too.
Contract notes (docs/API.md)
Everything consumed is in API.md. Places where the frontend had to interpret the contract:
GET /pressure/region/{id}and/pressure/country/{cc}:probes/targets/incidentsare counts in the list objects and lists in the detail objects — typed separately (RegionDetailResponse,CountryDetailResponse).GET /history/summarywithoutmonth: the month rows (months) are assumed to carry{month, min, max, avg, events}(mirrors thedaysshape withmonth: "YYYY-MM"); API.md only says "per-month rows".fronts[].statusvalues are not enumerated in API.md; the map animates the arc only fordeveloping/active.- The map's "Packet loss" mode derives per-region loss from
latency.matrix[].loss_pct(source view) because there is no loss component inregions[].components; countries are neutral in that mode. - Admin
GET /incidentsrows are assumed to carryreviewandnote(the PATCH body fields) so the review state can be displayed; API.md does not list them on the GET response. - Admin
GET /annotationsrows assumed{id?, ts, author?, scope_type, scope_id, text}.