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%
8.8 KB

# 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

text
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

bash
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

bash
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-web

output: '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/incidents are counts in the list objects and lists in the detail objects — typed separately (RegionDetailResponse, CountryDetailResponse).
  • GET /history/summary without month: the month rows (months) are assumed to carry {month, min, max, avg, events} (mirrors the days shape with month: "YYYY-MM"); API.md only says "per-month rows".
  • fronts[].status values are not enumerated in API.md; the map animates the arc only for developing/active.
  • The map's "Packet loss" mode derives per-region loss from latency.matrix[].loss_pct (source view) because there is no loss component in regions[].components; countries are neutral in that mode.
  • Admin GET /incidents rows are assumed to carry review and note (the PATCH body fields) so the review state can be displayed; API.md does not list them on the GET response.
  • Admin GET /annotations rows assumed {id?, ts, author?, scope_type, scope_id, text}.