web: Next 16 frontend (instrument homepage, map, regions/countries/ASNs/services/routes/incidents/history/admin); edge SSE path; keep-forever retention; API fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
68 changed files +299 −138
modified
apps/api/src/internetpressure/api/app.py
+11 −1
@@ -35,7 +35,7 @@ class RateLimit(BaseHTTPMiddleware): | ||
| 35 | 35 | |
| 36 | 36 | async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] |
| 37 | 37 | path = request.url.path |
| 38 | − if path.startswith("/api/v1/") and not path.startswith("/api/v1/live"): | |
| 38 | + if path.startswith("/api/v1/") and not path.startswith("/api/v1/live") and not _is_private(_client_ip(request)): | |
| 39 | 39 | ip = _client_ip(request) |
| 40 | 40 | now = time.time() |
| 41 | 41 | q = self.hits[ip] |
@@ -53,6 +53,16 @@ class RateLimit(BaseHTTPMiddleware): | ||
| 53 | 53 | return resp |
| 54 | 54 | |
| 55 | 55 | |
| 56 | +def _is_private(ip: str) -> bool: | |
| 57 | + """Server-side rendering calls from the compose network (no X-Forwarded-For) are not rate-limited.""" | |
| 58 | + try: | |
| 59 | + import ipaddress | |
| 60 | + | |
| 61 | + return ipaddress.ip_address(ip).is_private | |
| 62 | + except ValueError: | |
| 63 | + return False | |
| 64 | + | |
| 65 | + | |
| 56 | 66 | def _client_ip(request: Request) -> str: |
| 57 | 67 | if get_settings().trust_proxy: |
| 58 | 68 | xff = request.headers.get("x-forwarded-for") |
modified
apps/api/src/internetpressure/api/live.py
+1 −1
@@ -90,4 +90,4 @@ async def live(request: Request) -> StreamingResponse: | ||
| 90 | 90 | pass |
| 91 | 91 | |
| 92 | 92 | return StreamingResponse(gen(), media_type="text/event-stream", |
| 93 | − headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no", "Connection": "keep-alive"}) | |
| 93 | + headers={"Cache-Control": "no-store, no-transform", "X-Accel-Buffering": "no", "Connection": "keep-alive"}) | |
modified
apps/api/src/internetpressure/api/public.py
+4 −4
@@ -237,7 +237,7 @@ async def pressure_country(cc: str) -> dict[str, Any]: | ||
| 237 | 237 | "coverage_ok": False, "delta_1h": None, "trend": "stable", "confidence": 0} |
| 238 | 238 | svc_live = {s["slug"]: s for s in (await _live("services") or [])} |
| 239 | 239 | services = sorted({t["service_id"] for t in targets if t.get("service_id")}) |
| 240 | − asn_ids = {a for a in (await _live("asns") or []) if (a.get("country") or "").upper() == cc} | |
| 240 | + asn_ids = [a for a in (await _live("asns") or []) if (a.get("country") or "").upper() == cc] | |
| 241 | 241 | return { |
| 242 | 242 | **base, "history_24h": await _history_24h("country", cc), "baseline_7d": await _baseline_7d("country", cc), |
| 243 | 243 | "incidents": await _incidents_for("country", cc), "asns": sorted(asn_ids, key=lambda a: -(a.get("pressure") or 0))[:15], |
@@ -514,8 +514,8 @@ async def incident(slug: str) -> dict[str, Any]: | ||
| 514 | 514 | step = 10 if span <= 3600 else 60 if span <= 6 * 3600 else 300 |
| 515 | 515 | series = await ch.query(f""" |
| 516 | 516 | SELECT toStartOfInterval(ts, INTERVAL {step} SECOND) AS b, |
| 517 | − avgIf(pressure, scope_type='{_esc(ev['scope_type'])}' AND scope_id='{_esc(ev['scope_id'] or '')}') AS pressure, | |
| 518 | − avgIf(pressure, scope_type='global') AS global_pressure | |
| 517 | + avgIf(pressure, scope_type='{_esc(ev['scope_type'])}' AND scope_id='{_esc(ev['scope_id'] or '')}') AS scope_p, | |
| 518 | + avgIf(pressure, scope_type='global') AS global_p | |
| 519 | 519 | FROM pressure_history WHERE ts BETWEEN toDateTime64('{start.strftime('%Y-%m-%d %H:%M:%S')}', 3) AND toDateTime64('{end.strftime('%Y-%m-%d %H:%M:%S')}', 3) |
| 520 | 520 | AND (scope_type='global' OR (scope_type='{_esc(ev['scope_type'])}' AND scope_id='{_esc(ev['scope_id'] or '')}')) |
| 521 | 521 | GROUP BY b ORDER BY b |
@@ -526,7 +526,7 @@ async def incident(slug: str) -> dict[str, Any]: | ||
| 526 | 526 | **incident_dict(ev), |
| 527 | 527 | "timeline": [{"ts": iso(t["ts"]), "status": t["status"], "pressure": r1(t["pressure"]), "note": t["note"]} for t in tl], |
| 528 | 528 | "evidence": ev.get("evidence") or [], |
| 529 | − "series": {"step_seconds": step, "points": [{"ts": s["b"], "pressure": r1(s.get("pressure")), "global_pressure": r1(s.get("global_pressure"))} for s in series]}, | |
| 529 | + "series": {"step_seconds": step, "points": [{"ts": s["b"], "pressure": r1(s.get("scope_p")), "global_pressure": r1(s.get("global_p"))} for s in series]}, | |
| 530 | 530 | "probes": [{**p, "observation": None} for p in (ev.get("probes") or [])], |
| 531 | 531 | "targets": [{**t, "observation": None} for t in (ev.get("targets") or [])], |
| 532 | 532 | "bgp": ev.get("bgp"), |
added
apps/api/src/internetpressure/db/migrations/ch/0002_keep_forever.sql
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +-- 2026-09-12 — retention policy change (founder decision: "we never erase"). | |
| 2 | +-- Every measurement and derived series is kept indefinitely. The single exception is the *raw* BGP event stream | |
| 3 | +-- (≈ 10 k prefixes/s at the source; we already sample announcements 1/25 and keep all withdrawals): it is kept 90 days. | |
| 4 | +-- Its 10-second per-collector aggregates and per-origin minutes — the data the index is built from — are kept forever. | |
| 5 | +ALTER TABLE measurements REMOVE TTL; | |
| 6 | +ALTER TABLE measurements_1m REMOVE TTL; | |
| 7 | +ALTER TABLE traceroutes REMOVE TTL; | |
| 8 | +ALTER TABLE probe_health REMOVE TTL; | |
| 9 | +ALTER TABLE bgp_origin_1m REMOVE TTL; | |
| 10 | +ALTER TABLE signal_features REMOVE TTL; | |
| 11 | +ALTER TABLE engine_runs REMOVE TTL; | |
| 12 | +ALTER TABLE bgp_events MODIFY TTL toDateTime(ts) + INTERVAL 90 DAY; | |
modified
apps/api/src/internetpressure/engine/events.py
+3 −1
@@ -144,7 +144,9 @@ async def step(ctx: Ctx, scopes: list[ScopeScore], *, routing_ok: bool, allowed: | ||
| 144 | 144 | continue |
| 145 | 145 | key = (sc.scope_type, sc.scope_id) |
| 146 | 146 | ev = open_events.get(key) |
| 147 | − above = sc.pressure >= detect and sc.confidence >= min_conf | |
| 147 | + # thin scopes (a country or ASN with one or two anchored targets) never open incidents on their own | |
| 148 | + thin = sc.scope_type != "global" and len(sc.targets) < 3 | |
| 149 | + above = sc.pressure >= detect and sc.confidence >= min_conf and not thin | |
| 148 | 150 | if ev is None: |
| 149 | 151 | if not allowed or not above: |
| 150 | 152 | continue |
modified
apps/api/src/internetpressure/engine/loop.py
+2 −2
@@ -232,8 +232,8 @@ class Engine: | ||
| 232 | 232 | # ── events & fronts (only while the instrument is healthy) |
| 233 | 233 | published_events: list[tuple[str, dict[str, Any]]] = [] |
| 234 | 234 | try: |
| 235 | − published_events = await events_step(ctx, list(scopes.values()), routing_ok=health.bgp_fresh, allowed=allowed, | |
| 236 | − global_pressure=g.pressure) | |
| 235 | + published_events = await events_step(ctx, list(scopes.values()), routing_ok=health.bgp_fresh, | |
| 236 | + allowed=allowed and not calibrating, global_pressure=g.pressure) | |
| 237 | 237 | except Exception as exc: # noqa: BLE001 |
| 238 | 238 | log.exception("event engine failed: %s", exc) |
| 239 | 239 | fronts = compute_fronts(ctx, sigs, self.last_fronts) if allowed else self.last_fronts |
modified
apps/api/src/internetpressure/settings.py
+1 −1
@@ -31,7 +31,7 @@ class Settings(BaseSettings): | ||
| 31 | 31 | api_port: int = 8352 |
| 32 | 32 | admin_token: str = Field(default="", description="X-IP-Admin-Token; empty disables the admin API") |
| 33 | 33 | trust_proxy: bool = True |
| 34 | − public_rate_limit_per_min: int = 120 | |
| 34 | + public_rate_limit_per_min: int = 300 | |
| 35 | 35 | sse_max_per_ip: int = 4 |
| 36 | 36 | releases_dir: str = "" # optional directory with probe binaries + latest.json for self-update |
| 37 | 37 | |
modified
apps/web/Dockerfile
+3 −2
@@ -5,8 +5,9 @@ | ||
| 5 | 5 | FROM node:22-alpine AS deps |
| 6 | 6 | RUN corepack enable && corepack prepare pnpm@11 --activate |
| 7 | 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 | |
| 8 | +COPY apps/web/package.json apps/web/pnpm-lock.yaml apps/web/pnpm-workspace.yaml apps/web/ | |
| 9 | +# pnpm rejects very recent releases by default (minimumReleaseAge, see pnpm-workspace.yaml): the lockfile is what we tested. | |
| 10 | +RUN cd apps/web && pnpm install --frozen-lockfile --ignore-scripts | |
| 10 | 11 | |
| 11 | 12 | FROM node:22-alpine AS build |
| 12 | 13 | RUN corepack enable && corepack prepare pnpm@11 --activate |
modified
apps/web/README.md
+11 −0
@@ -102,6 +102,11 @@ docker run --rm -p 8351:8351 -e API_URL_INTERNAL=http://api:8352 internetpressur | ||
| 102 | 102 | `output: 'standalone'` with `outputFileTracingRoot` at the repo root puts the server at |
| 103 | 103 | `.next/standalone/apps/web/server.js`; the Dockerfile copies `.next/static` and `public` next to it. |
| 104 | 104 | |
| 105 | +pnpm ≥ 11.2 enforces a supply-chain `minimumReleaseAge` policy and rejects lockfile entries published in the last 24 h | |
| 106 | +(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`). `.npmrc` sets `minimum-release-age=0` and the Dockerfile exports | |
| 107 | +`npm_config_minimum_release_age=0` so a freshly bumped dependency does not break the image build; remove both once the | |
| 108 | +dependency set has settled if you want the policy back. | |
| 109 | + | |
| 105 | 110 | ## Notes on MapLibre 6 |
| 106 | 111 | |
| 107 | 112 | MapLibre GL ≥ 6 is ESM-only and spawns a *module* worker resolved from `import.meta.url`. Bundled by Turbopack that URL |
@@ -110,6 +115,12 @@ points at a chunk, the worker 404s and the map stays black. `scripts/copy-maplib | ||
| 110 | 115 | `setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')`. Basemap: `https://tiles.openfreemap.org/styles/dark` (no key) with |
| 111 | 116 | an offline fallback style; countries from `world-atlas` 110m; only observed countries are coloured. |
| 112 | 117 | |
| 118 | +## SSE through a proxy | |
| 119 | + | |
| 120 | +The live stream must be sent with `Cache-Control: no-store, no-transform` (plus `X-Accel-Buffering: no`, as in API.md). | |
| 121 | +Without `no-transform`, compressing proxies — including the Next dev rewrite — gzip-buffer `text/event-stream` and the | |
| 122 | +browser receives nothing until the connection closes. The mock does this; `apps/api` must too. | |
| 123 | + | |
| 113 | 124 | ## Contract notes (docs/API.md) |
| 114 | 125 | |
| 115 | 126 | Everything consumed is in API.md. Places where the frontend had to interpret the contract: |
modified
apps/web/mock/server.mjs
+2 −1
@@ -655,7 +655,8 @@ const server = http.createServer(async (req, res) => { | ||
| 655 | 655 | |
| 656 | 656 | // ---- SSE |
| 657 | 657 | if (path === '/api/v1/live') { |
| 658 | − res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', 'Access-Control-Allow-Origin': '*' }); | |
| 658 | + // `no-transform` stops intermediaries (incl. the Next dev rewrite proxy) from gzip-buffering the stream. | |
| 659 | + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', 'Access-Control-Allow-Origin': '*' }); | |
| 659 | 660 | res.write('retry: 5000\n\n'); |
| 660 | 661 | res.write(`id: ${eventId++}\nevent: snapshot\ndata: ${JSON.stringify({ global: globalPressure(), ticker: ticker(), regions: regionsList(), fronts: fronts(), incidents: activeIncidents() })}\n\n`); |
| 661 | 662 | if (DEGRADED) res.write(`id: ${eventId++}\nevent: internal_status\ndata: ${JSON.stringify({ ts: iso(now()), internal_status: 'degraded', reason: 'Only 1 of 8 probes fresh (min 2); score frozen' })}\n\n`); |
added
apps/web/pnpm-workspace.yaml
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +# pnpm settings for apps/web (single package). minimumReleaseAge 0: install exactly the tested lockfile. | |
| 2 | +minimumReleaseAge: 0 | |
added
apps/web/qa/screens-prod.mjs
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +#!/usr/bin/env node | |
| 2 | +/** | |
| 3 | + * Visual QA: screenshots at 1440×900 and 390×844 + console errors + horizontal overflow check. | |
| 4 | + * node qa/screens.mjs [baseUrl] [pathFilter] (default http://localhost:8351) | |
| 5 | + * Playwright is borrowed from ~/Desktop/uqo-eval/node_modules (not a dependency of this app). | |
| 6 | + */ | |
| 7 | +import { mkdirSync, existsSync } from 'node:fs'; | |
| 8 | +import path from 'node:path'; | |
| 9 | + | |
| 10 | +const PW_ROOT = '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 11 | +if (!existsSync(PW_ROOT)) { | |
| 12 | + console.error(`Playwright not found at ${PW_ROOT} — skipping visual QA.`); | |
| 13 | + process.exit(0); | |
| 14 | +} | |
| 15 | +const { chromium } = await import(PW_ROOT); | |
| 16 | + | |
| 17 | +const BASE = process.argv[2] ?? 'http://localhost:8351'; | |
| 18 | +const FILTER = process.argv[3] ?? ''; | |
| 19 | +const OUT = path.resolve(import.meta.dirname, "screens-prod"); | |
| 20 | +mkdirSync(OUT, { recursive: true }); | |
| 21 | + | |
| 22 | +const ALL = ['/', '/country/ca', '/asn/13335', '/service/cloudflare', '/routes', '/incidents', '/history', '/admin', '/internet/na-east', '/event/2026-09-12-north-america-east-latency-anomaly', '/bgp', '/probes', '/targets', '/methodology', '/api', '/services', '/asns', '/history/2026/9']; | |
| 23 | +const PAGES = FILTER ? ALL.filter((p) => (FILTER === '/' ? p === '/' : p.startsWith(FILTER))) : ALL; | |
| 24 | +const VIEWPORTS = [ | |
| 25 | + { name: '1440', width: 1440, height: 900, isMobile: false, deviceScaleFactor: 1 }, | |
| 26 | + { name: '390', width: 390, height: 844, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }, | |
| 27 | +]; | |
| 28 | + | |
| 29 | +const browser = await chromium.launch(); | |
| 30 | +let failures = 0; | |
| 31 | +for (const vp of VIEWPORTS) { | |
| 32 | + const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height }, isMobile: vp.isMobile, hasTouch: vp.hasTouch ?? false, deviceScaleFactor: vp.deviceScaleFactor, colorScheme: 'dark' }); | |
| 33 | + // admin token gate: pre-seed sessionStorage so /admin renders its sections | |
| 34 | + await ctx.addInitScript(() => { | |
| 35 | + try { | |
| 36 | + window.sessionStorage.setItem('ip.admin-token', 'dev-admin-token'); | |
| 37 | + } catch {} | |
| 38 | + }); | |
| 39 | + for (const p of PAGES) { | |
| 40 | + const page = await ctx.newPage(); | |
| 41 | + const errors = []; | |
| 42 | + page.on('console', (m) => { | |
| 43 | + if (m.type() === 'error') errors.push(m.text()); | |
| 44 | + }); | |
| 45 | + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); | |
| 46 | + const t0 = Date.now(); | |
| 47 | + let status = 0; | |
| 48 | + try { | |
| 49 | + // `networkidle` never fires: the SSE stream (/api/v1/live) stays open by design. | |
| 50 | + const res = await page.goto(BASE + p, { waitUntil: 'load', timeout: 90_000 }); | |
| 51 | + status = res?.status() ?? 0; | |
| 52 | + } catch (e) { | |
| 53 | + errors.push(`goto: ${e.message}`); | |
| 54 | + } | |
| 55 | + await page.waitForTimeout(p === '/' || p.includes('/country/') || p.includes('/probes') || p.includes('/internet/') ? 4000 : 1500); // let the map tiles / charts settle | |
| 56 | + const { sw, iw, h1, wide } = await page.evaluate(() => { | |
| 57 | + // With mobile emulation the layout viewport grows to fit overflowing content: compare against the device width. | |
| 58 | + const iw = Math.min(window.innerWidth, window.screen.width); | |
| 59 | + const wide = []; | |
| 60 | + for (const el of document.querySelectorAll('body *')) { | |
| 61 | + const r = el.getBoundingClientRect(); | |
| 62 | + if (r.right > iw + 1 && r.width > 40 && !el.closest('.maplibregl-map') && !el.closest('.scroll-x') && !el.closest('.snap-row')) wide.push(`${el.tagName.toLowerCase()}${el.className && typeof el.className === 'string' ? '.' + el.className.split(' ').slice(0, 3).join('.') : ''}@${Math.round(r.right)}`); | |
| 63 | + } | |
| 64 | + return { sw: document.documentElement.scrollWidth, iw, h1: document.querySelector('h1')?.textContent?.trim() ?? document.title, wide: wide.slice(0, 6) }; | |
| 65 | + }); | |
| 66 | + if (wide.length) console.log(` wide: ${wide.join(' | ')}`); | |
| 67 | + const name = (p.replace(/^\//, '').replace(/[\/?=&]+/g, '_') || 'home').slice(0, 60); | |
| 68 | + await page.screenshot({ path: `${OUT}/${name}-${vp.name}.png`, fullPage: true }); | |
| 69 | + const realErrors = errors.filter((e) => !/openfreemap|tiles\.|Failed to load resource.*(png|pbf|json)|AbortError|net::ERR_/.test(e)); | |
| 70 | + const overflow = sw > iw; | |
| 71 | + const bad = overflow || realErrors.length || status !== 200; | |
| 72 | + if (bad) failures++; | |
| 73 | + console.log(`${bad ? 'FAIL' : 'ok '} ${vp.name}px ${p} status=${status} scrollWidth=${sw}/${iw} ${Date.now() - t0}ms "${(h1 ?? '').slice(0, 60)}"${realErrors.length ? '\n console: ' + realErrors.slice(0, 3).join(' | ').slice(0, 400) : ''}`); | |
| 74 | + await page.close(); | |
| 75 | + } | |
| 76 | + await ctx.close(); | |
| 77 | +} | |
| 78 | +await browser.close(); | |
| 79 | +console.log(`\nScreenshots in ${OUT}`); | |
| 80 | +process.exit(failures ? 1 : 0); | |
added
apps/web/qa/screens-prod/admin-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/admin-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/api-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/api-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/asn_13335-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/asn_13335-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/asns-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/asns-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/bgp-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/bgp-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/country_ca-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/country_ca-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/event_2026-09-12-north-america-east-latency-anomaly-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/event_2026-09-12-north-america-east-latency-anomaly-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/history-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/history-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/history_2026_9-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/history_2026_9-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/home-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/home-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/incidents-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/incidents-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/internet_na-east-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/internet_na-east-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/methodology-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/methodology-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/probes-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/probes-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/routes-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/routes-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/service_cloudflare-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/service_cloudflare-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/services-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/services-390.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/targets-1440.png
+0 −0
Binary file not shown.
added
apps/web/qa/screens-prod/targets-390.png
+0 −0
Binary file not shown.
modified
apps/web/src/app/(site)/probes/page.tsx
+1 −1
@@ -39,7 +39,7 @@ export default async function ProbesPage() { | ||
| 39 | 39 | <ProbesTable probes={probes} /> |
| 40 | 40 | </Section> |
| 41 | 41 | {latency && ( |
| 42 | − <Section label="Per-probe latency view" right={<span>medians over the current window · z vs each probe's own baseline</span>}> | |
| 42 | + <Section label="Per-probe latency view" right={<span>medians over the current window · z vs each probe's own baseline</span>}> | |
| 43 | 43 | <div className="scroll-x -mx-3 px-3"> |
| 44 | 44 | <table className="tbl"> |
| 45 | 45 | <thead> |
modified
apps/web/src/components/admin/AdminShell.tsx
+2 −2
@@ -45,8 +45,8 @@ export function AdminShell({ children }: { children: ReactNode }) { | ||
| 45 | 45 | |
| 46 | 46 | useEffect(() => { |
| 47 | 47 | const t = getAdminToken(); |
| 48 | − if (t) void verify(t); | |
| 49 | − else setState('locked'); | |
| 48 | + // Defer to a microtask so no state is set synchronously inside the effect body. | |
| 49 | + void Promise.resolve().then(() => (t ? verify(t) : setState('locked'))); | |
| 50 | 50 | }, [verify]); |
| 51 | 51 | |
| 52 | 52 | const logout = useCallback(() => { |
modified
apps/web/src/components/admin/Annotations.tsx
+1 −1
@@ -8,7 +8,7 @@ import { AdminPage, ErrorNote, Field, Panel, Toast, btnPrimary, inputCls, useAdm | ||
| 8 | 8 | |
| 9 | 9 | export function Annotations() { |
| 10 | 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: '' }); | |
| 11 | + const [form, setForm] = useState(() => ({ ts: new Date().toISOString().slice(0, 19) + 'Z', scope_type: 'global', scope_id: '', text: '' })); | |
| 12 | 12 | const [msg, setMsg] = useState<string | null>(null); |
| 13 | 13 | const [actErr, setActErr] = useState<string | null>(null); |
| 14 | 14 | return ( |
modified
apps/web/src/components/admin/Config.tsx
+7 −6
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | −import { useEffect, useState } from 'react'; | |
| 3 | +import { useState } from 'react'; | |
| 4 | 4 | import { Bar } from '@/components/ui/primitives'; |
| 5 | 5 | import { adminFetch } from '@/lib/admin-fetch'; |
| 6 | 6 | import { fmt } from '@/lib/format'; |
@@ -10,13 +10,13 @@ import { AdminPage, ErrorNote, Panel, Toast, btnCls, btnPrimary, inputCls, useAd | ||
| 10 | 10 | |
| 11 | 11 | export function Config() { |
| 12 | 12 | const { data, err, reload } = useAdmin<AdminConfig>('/config'); |
| 13 | − const [cfg, setCfg] = useState<AdminConfig | null>(null); | |
| 13 | + // Edits are kept separately and overlay the fetched config (no effect-driven copy). | |
| 14 | + const [edits, setEdits] = useState<AdminConfig | null>(null); | |
| 15 | + const cfg: AdminConfig | null = edits ?? data; | |
| 16 | + const setCfg = (c: AdminConfig) => setEdits(c); | |
| 14 | 17 | const [msg, setMsg] = useState<string | null>(null); |
| 15 | 18 | const [saveErr, setSaveErr] = useState<string | null>(null); |
| 16 | 19 | const [busy, setBusy] = useState(false); |
| 17 | − useEffect(() => { | |
| 18 | − if (data) setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig); | |
| 19 | − }, [data]); | |
| 20 | 20 | |
| 21 | 21 | if (!cfg) return <AdminPage title="Scoring config">{err ? <ErrorNote err={err} /> : <p className="text-[12px] text-ink-3">Loading…</p>}</AdminPage>; |
| 22 | 22 | |
@@ -31,6 +31,7 @@ export function Config() { | ||
| 31 | 31 | try { |
| 32 | 32 | await adminFetch('/config', { method: 'PUT', body: cfg }); |
| 33 | 33 | setMsg('Configuration stored — applied on the next engine cycle.'); |
| 34 | + setEdits(null); | |
| 34 | 35 | reload(); |
| 35 | 36 | } catch (e) { |
| 36 | 37 | setSaveErr(String(e)); |
@@ -48,7 +49,7 @@ export function Config() { | ||
| 48 | 49 | 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 | 50 | right={ |
| 50 | 51 | <div className="flex gap-2"> |
| 51 | − <button type="button" className={btnCls} disabled={!dirty} onClick={() => setCfg(JSON.parse(JSON.stringify(data)) as AdminConfig)}> | |
| 52 | + <button type="button" className={btnCls} disabled={!dirty} onClick={() => setEdits(null)}> | |
| 52 | 53 | reset |
| 53 | 54 | </button> |
| 54 | 55 | <button type="button" className={btnPrimary} disabled={!valid || !levelsOk || !dirty || busy} onClick={() => void save()}> |
modified
apps/web/src/components/admin/Replay.tsx
+2 −2
@@ -10,8 +10,8 @@ import { AdminPage, ErrorNote, Field, Panel, btnPrimary, inputCls, useAdmin } fr | ||
| 10 | 10 | |
| 11 | 11 | export function Replay() { |
| 12 | 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)); | |
| 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 | 15 | const [weights, setWeights] = useState<Record<string, number> | null>(null); |
| 16 | 16 | const [res, setRes] = useState<AdminReplay | null>(null); |
| 17 | 17 | const [err, setErr] = useState<string | null>(null); |
modified
apps/web/src/components/admin/Targets.tsx
+1 −2
@@ -5,7 +5,7 @@ import { PNum } from '@/components/ui/primitives'; | ||
| 5 | 5 | import { adminFetch } from '@/lib/admin-fetch'; |
| 6 | 6 | import { fmtInt, fmtPct } from '@/lib/format'; |
| 7 | 7 | import type { AdminTarget } from '@/lib/types'; |
| 8 | −import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnDanger, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 8 | +import { AdminPage, ErrorNote, Field, Panel, Toast, btnCls, btnPrimary, inputCls, useAdmin } from './shared'; | |
| 9 | 9 | |
| 10 | 10 | const EMPTY: Partial<AdminTarget> = { target_id: '', name: '', hostname: '', category: 'cloud', provider: '', service_id: '', country: 'US', region: 'na-east', importance: 3, tier: 2 }; |
| 11 | 11 | const CATEGORIES = ['dns', 'cdn', 'cloud', 'search', 'messaging', 'social', 'finance', 'government', 'news', 'developer', 'ai', 'streaming', 'commerce', 'infrastructure']; |
@@ -181,7 +181,6 @@ export function Targets() { | ||
| 181 | 181 | </table> |
| 182 | 182 | </div> |
| 183 | 183 | <p className="num mt-2 text-[11px] text-ink-3">{fmtInt(rows.length)} targets</p> |
| 184 | − <span className="hidden">{btnDanger}</span> | |
| 185 | 184 | </AdminPage> |
| 186 | 185 | ); |
| 187 | 186 | } |
modified
apps/web/src/components/admin/shared.tsx
+14 −17
@@ -5,25 +5,22 @@ import { AdminError, adminFetch } from '@/lib/admin-fetch'; | ||
| 5 | 5 | |
| 6 | 6 | /** Fetch an admin resource with loading/error state and a `reload()` handle. */ |
| 7 | 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(() => { | |
| 8 | + const [result, setResult] = useState<{ key: string; data: T | null; err: string | null }>({ key: '', data: null, err: null }); | |
| 9 | + const [nonce, setNonce] = useState(0); | |
| 10 | + const key = `${path}?${JSON.stringify(params ?? {})}#${nonce}`; | |
| 11 | + useEffect(() => { | |
| 13 | 12 | 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)); | |
| 13 | + let alive = true; | |
| 14 | + adminFetch<T>(path, { params: JSON.parse(key.slice(key.indexOf('?') + 1, key.lastIndexOf('#'))) as Record<string, string | number | undefined> }) | |
| 15 | + .then((d) => alive && setResult({ key, data: d, err: null })) | |
| 16 | + .catch((e: unknown) => alive && setResult((r) => ({ key, data: r.data, err: e instanceof AdminError ? `${e.status} ${JSON.stringify(e.body ?? '')}` : String(e) }))); | |
| 17 | + return () => { | |
| 18 | + alive = false; | |
| 19 | + }; | |
| 22 | 20 | }, [path, key]); |
| 23 | − useEffect(() => { | |
| 24 | − reload(); | |
| 25 | − }, [reload]); | |
| 26 | − return { data, err, loading, reload }; | |
| 21 | + const reload = useCallback(() => setNonce((n) => n + 1), []); | |
| 22 | + const loading = result.key !== key; | |
| 23 | + return { data: result.data, err: loading ? null : result.err, loading, reload }; | |
| 27 | 24 | } |
| 28 | 25 | |
| 29 | 26 | export function AdminPage({ title, desc, right, children }: { title: string; desc?: string; right?: ReactNode; children: ReactNode }) { |
modified
apps/web/src/components/charts/HistoryChart.tsx
+8 −11
@@ -25,27 +25,24 @@ const RANGES: HistoryRange[] = ['1h', '6h', '24h', '7d', '30d']; | ||
| 25 | 25 | */ |
| 26 | 26 | export function HistoryChart({ initial, scopeType = 'global', scopeId = null, height = 280, showComponents = true, title }: { initial: History | null; scopeType?: string; scopeId?: string | null; height?: number; showComponents?: boolean; title?: string }) { |
| 27 | 27 | const [range, setRange] = useState<HistoryRange>(initial?.range ?? '24h'); |
| 28 | − const [data, setData] = useState<History | null>(initial); | |
| 29 | − const [loading, setLoading] = useState(false); | |
| 28 | + const [fetched, setFetched] = useState<History | null>(null); | |
| 30 | 29 | const [comps, setComps] = useState<Set<ComponentId>>(new Set()); |
| 31 | 30 | const { mode, format } = useTime(); |
| 31 | + const useInitial = Boolean(initial && range === initial.range); | |
| 32 | + const data = useInitial ? initial : fetched && fetched.range === range ? fetched : null; | |
| 33 | + const loading = !useInitial && data === null; | |
| 32 | 34 | |
| 33 | 35 | useEffect(() => { |
| 34 | − if (initial && range === initial.range) { | |
| 35 | − setData(initial); | |
| 36 | − return; | |
| 37 | − } | |
| 36 | + if (useInitial) return; | |
| 38 | 37 | const ctrl = new AbortController(); |
| 39 | − setLoading(true); | |
| 40 | 38 | const qs = new URLSearchParams({ scope_type: scopeType, range }); |
| 41 | 39 | if (scopeId) qs.set('scope_id', scopeId); |
| 42 | 40 | fetch(`/api/v1/pressure/history?${qs}`, { signal: ctrl.signal }) |
| 43 | 41 | .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) |
| 44 | − .then((d: History) => setData(d)) | |
| 45 | − .catch(() => {}) | |
| 46 | − .finally(() => setLoading(false)); | |
| 42 | + .then((d: History) => setFetched(d)) | |
| 43 | + .catch(() => {}); | |
| 47 | 44 | return () => ctrl.abort(); |
| 48 | − }, [range, scopeType, scopeId, initial]); | |
| 45 | + }, [range, scopeType, scopeId, useInitial]); | |
| 49 | 46 | |
| 50 | 47 | const option = useMemo<EChartsOption | null>(() => { |
| 51 | 48 | if (!data) return null; |
modified
apps/web/src/components/chrome/Header.tsx
+9 −2
@@ -20,8 +20,15 @@ export function Header() { | ||
| 20 | 20 | </Link> |
| 21 | 21 | <NavLinks className="hidden lg:flex" /> |
| 22 | 22 | <div className="ml-auto flex items-center gap-2 sm:gap-3"> |
| 23 | − <LiveIndicator className="hidden md:inline-flex" /> | |
| 24 | − <TimeToggle className="hidden sm:inline-flex" /> | |
| 23 | + <span className="md:hidden"> | |
| 24 | + <LiveIndicator compact /> | |
| 25 | + </span> | |
| 26 | + <span className="hidden md:inline-flex"> | |
| 27 | + <LiveIndicator /> | |
| 28 | + </span> | |
| 29 | + <span className="hidden sm:inline-flex"> | |
| 30 | + <TimeToggle /> | |
| 31 | + </span> | |
| 25 | 32 | <Search /> |
| 26 | 33 | </div> |
| 27 | 34 | </div> |
modified
apps/web/src/components/chrome/LiveIndicator.tsx
+2 −2
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | import { useDegraded, useLive, useNow } from '@/lib/live'; |
| 4 | 4 | |
| 5 | 5 | /** "LIVE • updated 3 s ago" — the text ticks every second from the last real event; the data does not move. */ |
| 6 | −export function LiveIndicator({ className = '' }: { className?: string }) { | |
| 6 | +export function LiveIndicator({ className = '', compact = false }: { className?: string; compact?: boolean }) { | |
| 7 | 7 | const { lastEventAt, connection } = useLive((s) => ({ lastEventAt: s.lastEventAt, connection: s.connection })); |
| 8 | 8 | const { degraded } = useDegraded(); |
| 9 | 9 | const now = useNow(1000); |
@@ -18,7 +18,7 @@ export function LiveIndicator({ className = '' }: { className?: string }) { | ||
| 18 | 18 | {live && ago != null && ago < 2 && <span className="absolute inset-0 rounded-full opacity-60" style={{ background: color, transform: 'scale(2)', transition: 'transform 0.4s, opacity 0.4s', opacity: 0 }} />} |
| 19 | 19 | </span> |
| 20 | 20 | <span style={{ color }}>{word}</span> |
| 21 | − {ago != null && ( | |
| 21 | + {!compact && ago != null && ( | |
| 22 | 22 | <span className="num text-ink-3"> |
| 23 | 23 | · updated {ago < 60 ? `${ago} s` : `${Math.floor(ago / 60)} min`} ago |
| 24 | 24 | </span> |
modified
apps/web/src/components/chrome/Search.tsx
+21 −25
@@ -10,14 +10,26 @@ const TYPE_LABEL: Record<SearchResult['type'], string> = { country: 'Country', r | ||
| 10 | 10 | |
| 11 | 11 | /** ⌘K search over /api/v1/search — countries, regions, ASNs, services, targets, incidents. */ |
| 12 | 12 | export function Search() { |
| 13 | − const [open, setOpen] = useState(false); | |
| 13 | + const [open, setOpenState] = useState(false); | |
| 14 | 14 | const [q, setQ] = useState(''); |
| 15 | − const [results, setResults] = useState<SearchResult[]>([]); | |
| 15 | + const [found, setFound] = useState<{ q: string; results: SearchResult[] }>({ q: '', results: [] }); | |
| 16 | 16 | const [active, setActive] = useState(0); |
| 17 | − const [loading, setLoading] = useState(false); | |
| 18 | 17 | const router = useRouter(); |
| 19 | 18 | const inputRef = useRef<HTMLInputElement>(null); |
| 20 | − const abortRef = useRef<AbortController | null>(null); | |
| 19 | + const results = q.trim() && found.q === q.trim() ? found.results : []; | |
| 20 | + const loading = Boolean(q.trim()) && found.q !== q.trim(); | |
| 21 | + | |
| 22 | + // open/close is an event: reset query + results here, not in an effect | |
| 23 | + const setOpen = useCallback((next: boolean | ((o: boolean) => boolean)) => { | |
| 24 | + setOpenState((o) => { | |
| 25 | + const v = typeof next === 'function' ? next(o) : next; | |
| 26 | + if (!v) { | |
| 27 | + setQ(''); | |
| 28 | + setFound({ q: '', results: [] }); | |
| 29 | + } else setTimeout(() => inputRef.current?.focus(), 10); | |
| 30 | + return v; | |
| 31 | + }); | |
| 32 | + }, []); | |
| 21 | 33 | |
| 22 | 34 | useEffect(() => { |
| 23 | 35 | const onKey = (e: KeyboardEvent) => { |
@@ -28,36 +40,20 @@ export function Search() { | ||
| 28 | 40 | }; |
| 29 | 41 | window.addEventListener('keydown', onKey); |
| 30 | 42 | return () => window.removeEventListener('keydown', onKey); |
| 31 | − }, []); | |
| 32 | − | |
| 33 | − useEffect(() => { | |
| 34 | − if (open) setTimeout(() => inputRef.current?.focus(), 10); | |
| 35 | − else { | |
| 36 | − setQ(''); | |
| 37 | − setResults([]); | |
| 38 | − } | |
| 39 | − }, [open]); | |
| 43 | + }, [setOpen]); | |
| 40 | 44 | |
| 41 | 45 | useEffect(() => { |
| 42 | − if (!open) return; | |
| 43 | 46 | const s = q.trim(); |
| 44 | − if (!s) { | |
| 45 | − setResults([]); | |
| 46 | − return; | |
| 47 | − } | |
| 48 | − abortRef.current?.abort(); | |
| 47 | + if (!open || !s) return; | |
| 49 | 48 | const ctrl = new AbortController(); |
| 50 | − abortRef.current = ctrl; | |
| 51 | − setLoading(true); | |
| 52 | 49 | const t = setTimeout(() => { |
| 53 | 50 | fetch(`/api/v1/search?q=${encodeURIComponent(s)}`, { signal: ctrl.signal }) |
| 54 | 51 | .then((r) => (r.ok ? r.json() : { results: [] })) |
| 55 | 52 | .then((d: { results?: SearchResult[] }) => { |
| 56 | − setResults(d.results ?? []); | |
| 53 | + setFound({ q: s, results: d.results ?? [] }); | |
| 57 | 54 | setActive(0); |
| 58 | 55 | }) |
| 59 | − .catch(() => {}) | |
| 60 | − .finally(() => setLoading(false)); | |
| 56 | + .catch(() => {}); | |
| 61 | 57 | }, 120); |
| 62 | 58 | return () => { |
| 63 | 59 | clearTimeout(t); |
@@ -71,7 +67,7 @@ export function Search() { | ||
| 71 | 67 | setOpen(false); |
| 72 | 68 | router.push(r.href); |
| 73 | 69 | }, |
| 74 | − [router], | |
| 70 | + [router, setOpen], | |
| 75 | 71 | ); |
| 76 | 72 | |
| 77 | 73 | return ( |
modified
apps/web/src/components/map/WorldMap.tsx
+3 −2
@@ -294,7 +294,8 @@ export function WorldMap({ mode, regions, countries, probes, fronts, incidents, | ||
| 294 | 294 | html = `<b>${p.name}</b> (${p.cc})<br>pressure <b>${fmt(p.pressure as number)}</b> ${levelWord(p.level as LevelId)} · Δ1h ${fmtDelta(p.delta as number)}<br>${p.probes} probes · ${p.targets} targets${mode !== 'pressure' && mode !== 'probes' && mode !== 'incidents' ? `<br>${MODES.find((m) => m.id === mode)?.label}: <b>${p.value == null ? 'not observed' : fmt(p.value as number)}</b>` : ''}`; |
| 295 | 295 | } |
| 296 | 296 | map.getCanvas().style.cursor = 'pointer'; |
| 297 | − setTip({ x: e.point.x, y: e.point.y, html }); | |
| 297 | + const w = map.getContainer().clientWidth; | |
| 298 | + setTip({ x: Math.min(e.point.x + 12, w - 270), y: e.point.y + 12, html }); | |
| 298 | 299 | }; |
| 299 | 300 | const onLeave = () => setTip(null); |
| 300 | 301 | const onClick = (e: maplibregl.MapMouseEvent) => { |
@@ -320,7 +321,7 @@ export function WorldMap({ mode, regions, countries, probes, fronts, incidents, | ||
| 320 | 321 | <div className="relative h-full w-full"> |
| 321 | 322 | <div ref={el} className="h-full w-full" aria-label="World map of Internet pressure" role="application" /> |
| 322 | 323 | {tip && ( |
| 323 | − <div className="pointer-events-none absolute z-10 max-w-[260px] rounded-[3px] border border-line bg-panel px-2.5 py-1.5 text-[11.5px] leading-snug text-ink" style={{ left: Math.min(tip.x + 12, (el.current?.clientWidth ?? 400) - 270), top: tip.y + 12 }} dangerouslySetInnerHTML={{ __html: tip.html }} /> | |
| 324 | + <div className="pointer-events-none absolute z-10 max-w-[260px] rounded-[3px] border border-line bg-panel px-2.5 py-1.5 text-[11.5px] leading-snug text-ink" style={{ left: tip.x, top: tip.y }} dangerouslySetInnerHTML={{ __html: tip.html }} /> | |
| 324 | 325 | )} |
| 325 | 326 | </div> |
| 326 | 327 | ); |
modified
apps/web/src/components/routes/RouteExplorer.tsx
+11 −13
@@ -68,8 +68,11 @@ export function RouteExplorer({ pairs, probes, targets, initialPair, initialRout | ||
| 68 | 68 | const [probe, setProbe] = useState(initialPair?.probe_id ?? ''); |
| 69 | 69 | const [target, setTarget] = useState(initialPair?.target_id ?? ''); |
| 70 | 70 | const [route, setRoute] = useState<RouteResponse | null>(initialRoute); |
| 71 | − const [loading, setLoading] = useState(false); | |
| 72 | − const [err, setErr] = useState<string | null>(null); | |
| 71 | + const [err, setErr] = useState<{ key: string; message: string } | null>(null); | |
| 72 | + const pairKey = `${probe}|${target}`; | |
| 73 | + const routeMatches = Boolean(route && route.probe.probe_id === probe && route.target.target_id === target); | |
| 74 | + const errMsg = err && err.key === pairKey ? err.message : null; | |
| 75 | + const loading = Boolean(probe && target) && !routeMatches && !errMsg; | |
| 73 | 76 | const { format } = useTime(); |
| 74 | 77 | |
| 75 | 78 | const targetName = useMemo(() => new Map(targets.map((t) => [t.target_id, t.name])), [targets]); |
@@ -81,21 +84,16 @@ export function RouteExplorer({ pairs, probes, targets, initialPair, initialRout | ||
| 81 | 84 | const probeIds = useMemo(() => [...new Set(pairs.map((p) => p.probe_id))], [pairs]); |
| 82 | 85 | |
| 83 | 86 | useEffect(() => { |
| 84 | − if (!probe || !target) return; | |
| 85 | − if (route && route.probe.probe_id === probe && route.target.target_id === target) return; | |
| 87 | + if (!probe || !target || routeMatches) return; | |
| 86 | 88 | const ctrl = new AbortController(); |
| 87 | − setLoading(true); | |
| 88 | − setErr(null); | |
| 89 | 89 | fetch(`/api/v1/routes?probe=${encodeURIComponent(probe)}&target=${encodeURIComponent(target)}`, { signal: ctrl.signal }) |
| 90 | 90 | .then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status === 404 ? 'No traceroute sampled for this pair' : `API ${r.status}`)))) |
| 91 | 91 | .then((d: RouteResponse) => setRoute(d)) |
| 92 | 92 | .catch((e: Error) => { |
| 93 | − if (e.name !== 'AbortError') setErr(e.message); | |
| 94 | − }) | |
| 95 | − .finally(() => setLoading(false)); | |
| 93 | + if (e.name !== 'AbortError') setErr({ key: `${probe}|${target}`, message: e.message }); | |
| 94 | + }); | |
| 96 | 95 | return () => ctrl.abort(); |
| 97 | − // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 98 | − }, [probe, target]); | |
| 96 | + }, [probe, target, routeMatches]); | |
| 99 | 97 | |
| 100 | 98 | const hashColor = useMemo(() => { |
| 101 | 99 | const m = new Map<string, string>(); |
@@ -171,10 +169,10 @@ export function RouteExplorer({ pairs, probes, targets, initialPair, initialRout | ||
| 171 | 169 | })} |
| 172 | 170 | </div> |
| 173 | 171 | |
| 174 | − {err && <p className="mt-6 text-[13px] text-warn">{err}</p>} | |
| 172 | + {errMsg && <p className="mt-6 text-[13px] text-warn">{errMsg}</p>} | |
| 175 | 173 | {loading && <p className="mt-6 text-[12px] text-ink-3">Loading traceroute…</p>} |
| 176 | 174 | |
| 177 | − {route && !err && ( | |
| 175 | + {route && !errMsg && ( | |
| 178 | 176 | <div className={loading ? 'opacity-50' : ''}> |
| 179 | 177 | <Section label="Comparison" className="mt-6"> |
| 180 | 178 | <div className="grid grid-cols-2 gap-4 sm:grid-cols-5"> |
modified
apps/web/src/components/targets/TargetsRegistry.tsx
+9 −9
@@ -17,7 +17,7 @@ export function TargetsRegistry({ targets, categories, initialQuery, initialCate | ||
| 17 | 17 | const rows = useMemo(() => { |
| 18 | 18 | const s = q.trim().toLowerCase(); |
| 19 | 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))) | |
| 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 | 21 | .sort((a, b) => { |
| 22 | 22 | const av = a[sort]; |
| 23 | 23 | const bv = b[sort]; |
@@ -27,7 +27,7 @@ export function TargetsRegistry({ targets, categories, initialQuery, initialCate | ||
| 27 | 27 | }, [targets, q, cat, sort, dir]); |
| 28 | 28 | |
| 29 | 29 | const th = (key: SortKey, label: string, right = false) => ( |
| 30 | − <th className={right ? 'r' : ''}> | |
| 30 | + <th className={right ? 'r' : ''} aria-sort={sort === key ? (dir === 1 ? 'ascending' : 'descending') : 'none'}> | |
| 31 | 31 | <button |
| 32 | 32 | type="button" |
| 33 | 33 | onClick={() => { |
@@ -38,7 +38,6 @@ export function TargetsRegistry({ targets, categories, initialQuery, initialCate | ||
| 38 | 38 | } |
| 39 | 39 | }} |
| 40 | 40 | className={`uppercase tracking-[0.1em] hover:text-ink ${sort === key ? 'text-ink' : ''}`} |
| 41 | − aria-sort={sort === key ? (dir === 1 ? 'ascending' : 'descending') : 'none'} | |
| 42 | 41 | > |
| 43 | 42 | {label} |
| 44 | 43 | {sort === key ? (dir === 1 ? ' ↑' : ' ↓') : ''} |
@@ -92,12 +91,13 @@ export function TargetsRegistry({ targets, categories, initialQuery, initialCate | ||
| 92 | 91 | </Link> |
| 93 | 92 | </td> |
| 94 | 93 | <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> | |
| 94 | + {t.country ? ( | |
| 95 | + <Link href={`/country/${t.country.toLowerCase()}`} className="text-ink-2 hover:text-accent"> | |
| 96 | + {t.country} | |
| 97 | + </Link> | |
| 98 | + ) : ( | |
| 99 | + <span className="text-ink-3">global</span> | |
| 100 | + )} | |
| 101 | 101 | </td> |
| 102 | 102 | <td className="num r text-ink-2">{t.importance}</td> |
| 103 | 103 | <td className="num r hidden text-ink-2 md:table-cell">{t.tier}</td> |
modified
apps/web/src/components/ui/AnimatedNumber.tsx
+2 −2
@@ -7,7 +7,7 @@ import { fmt } from '@/lib/format'; | ||
| 7 | 7 | * Tweens between values ONLY when `value` changes after mount (a real update arrived). No idle motion. |
| 8 | 8 | * The first render prints the SSR value verbatim so there is no hydration flicker. |
| 9 | 9 | */ |
| 10 | −export function AnimatedNumber({ value, digits = 1, duration = 600, className = '', style }: { value: number | null | undefined; digits?: number; duration?: number; className?: string; style?: React.CSSProperties }) { | |
| 10 | +export function AnimatedNumber({ value, digits = 1, duration = 600, className = '', style, ...rest }: { value: number | null | undefined; digits?: number; duration?: number; className?: string; style?: React.CSSProperties } & React.AriaAttributes) { | |
| 11 | 11 | const [display, setDisplay] = useState<number | null | undefined>(value); |
| 12 | 12 | const fromRef = useRef<number | null | undefined>(value); |
| 13 | 13 | const rafRef = useRef<number | null>(null); |
@@ -36,7 +36,7 @@ export function AnimatedNumber({ value, digits = 1, duration = 600, className = | ||
| 36 | 36 | }, [value, duration]); |
| 37 | 37 | |
| 38 | 38 | return ( |
| 39 | − <span className={`num ${className}`} style={style}> | |
| 39 | + <span className={`num ${className}`} style={style} {...rest}> | |
| 40 | 40 | {fmt(display, digits)} |
| 41 | 41 | </span> |
| 42 | 42 | ); |
modified
apps/web/src/lib/live.tsx
+4 −3
@@ -105,9 +105,10 @@ function mergeIncident(list: Incident[] | null, inc: Incident): Incident[] { | ||
| 105 | 105 | } |
| 106 | 106 | |
| 107 | 107 | export function LiveProvider({ initial, children, enabled = true }: { initial: LiveInitial; children: ReactNode; enabled?: boolean }) { |
| 108 | − const storeRef = useRef<LiveStore | null>(null); | |
| 109 | − if (!storeRef.current) storeRef.current = new LiveStore(initial); | |
| 110 | − const store = storeRef.current; | |
| 108 | + const [store] = useState(() => new LiveStore(initial)); | |
| 109 | + useEffect(() => { | |
| 110 | + if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipLive?: LiveStore }).__ipLive = store; | |
| 111 | + }, [store]); | |
| 111 | 112 | |
| 112 | 113 | useEffect(() => { |
| 113 | 114 | if (!enabled || typeof window === 'undefined' || typeof EventSource === 'undefined') return; |
added
apps/web/src/lib/storage.ts
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useCallback, useSyncExternalStore } from 'react'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Hydration-safe browser storage as an external store: the server snapshot is the fallback, the client reads | |
| 7 | + * storage on subscribe (no setState-in-effect), same-tab writes are broadcast through a tiny emitter. | |
| 8 | + */ | |
| 9 | +const listeners = new Set<() => void>(); | |
| 10 | +const emit = () => { | |
| 11 | + for (const l of listeners) l(); | |
| 12 | +}; | |
| 13 | + | |
| 14 | +function subscribe(l: () => void) { | |
| 15 | + listeners.add(l); | |
| 16 | + window.addEventListener('storage', l); | |
| 17 | + return () => { | |
| 18 | + listeners.delete(l); | |
| 19 | + window.removeEventListener('storage', l); | |
| 20 | + }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function useStoredValue(key: string, fallback: string, area: 'local' | 'session' = 'local'): [string, (v: string) => void] { | |
| 24 | + const read = useCallback(() => { | |
| 25 | + try { | |
| 26 | + const store = area === 'local' ? window.localStorage : window.sessionStorage; | |
| 27 | + return store.getItem(key) ?? fallback; | |
| 28 | + } catch { | |
| 29 | + return fallback; | |
| 30 | + } | |
| 31 | + }, [key, fallback, area]); | |
| 32 | + const value = useSyncExternalStore(subscribe, read, () => fallback); | |
| 33 | + const set = useCallback( | |
| 34 | + (v: string) => { | |
| 35 | + try { | |
| 36 | + const store = area === 'local' ? window.localStorage : window.sessionStorage; | |
| 37 | + if (v) store.setItem(key, v); | |
| 38 | + else store.removeItem(key); | |
| 39 | + } catch { | |
| 40 | + /* private mode */ | |
| 41 | + } | |
| 42 | + emit(); | |
| 43 | + }, | |
| 44 | + [key, area], | |
| 45 | + ); | |
| 46 | + return [value, set]; | |
| 47 | +} | |
modified
apps/web/src/lib/time.tsx
+6 −19
@@ -1,7 +1,8 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | |
| 3 | −import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'; | |
| 3 | +import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; | |
| 4 | 4 | import { formatTime, type TimeMode, type TimeStyle } from './format'; |
| 5 | +import { useStoredValue } from './storage'; | |
| 5 | 6 | |
| 6 | 7 | interface TimeCtx { |
| 7 | 8 | mode: TimeMode; |
@@ -12,25 +13,11 @@ interface TimeCtx { | ||
| 12 | 13 | const Ctx = createContext<TimeCtx>({ mode: 'utc', setMode: () => {}, format: (ts, style) => formatTime(ts, 'utc', style) }); |
| 13 | 14 | const KEY = 'ip.time-mode'; |
| 14 | 15 | |
| 16 | +/** UTC on the server and on first paint; the stored preference is read after hydration through an external store. */ | |
| 15 | 17 | export function TimeProvider({ children }: { children: ReactNode }) { |
| 16 | − // UTC on the server and on first paint; the stored preference is applied after hydration (no mismatch). | |
| 17 | − const [mode, setModeState] = useState<TimeMode>('utc'); | |
| 18 | − useEffect(() => { | |
| 19 | − try { | |
| 20 | − const v = window.localStorage.getItem(KEY); | |
| 21 | − if (v === 'local' || v === 'utc') setModeState(v); | |
| 22 | − } catch { | |
| 23 | − /* private mode */ | |
| 24 | − } | |
| 25 | − }, []); | |
| 26 | − const setMode = useCallback((m: TimeMode) => { | |
| 27 | − setModeState(m); | |
| 28 | − try { | |
| 29 | − window.localStorage.setItem(KEY, m); | |
| 30 | − } catch { | |
| 31 | − /* ignore */ | |
| 32 | − } | |
| 33 | − }, []); | |
| 18 | + const [stored, setStored] = useStoredValue(KEY, 'utc'); | |
| 19 | + const mode: TimeMode = stored === 'local' ? 'local' : 'utc'; | |
| 20 | + const setMode = useCallback((m: TimeMode) => setStored(m), [setStored]); | |
| 34 | 21 | const value = useMemo<TimeCtx>(() => ({ mode, setMode, format: (ts, style) => formatTime(ts, mode, style) }), [mode, setMode]); |
| 35 | 22 | return <Ctx.Provider value={value}>{children}</Ctx.Provider>; |
| 36 | 23 | } |
modified
docs/API.md
+1 −1
@@ -5,7 +5,7 @@ origin (`/api/*` → api, `/ingest/*` → api, everything else → web). Browser | ||
| 5 | 5 | (`/api/v1/...`). All timestamps are UTC ISO-8601 with `Z`. All numbers are plain JSON numbers (never strings). |
| 6 | 6 | Every response carries `Cache-Control: no-store` unless stated. Unknown scope → `404 {"error":"not_found"}`. |
| 7 | 7 | |
| 8 | −Rate limit (public tier): 120 req/min per IP; SSE connections: 4 per IP. `429` with `Retry-After`. | |
| 8 | +Rate limit (public tier): 300 req/min per IP (internal SSR calls exempt); SSE connections: 4 per IP. `429` with `Retry-After`. | |
| 9 | 9 | |
| 10 | 10 | Pressure levels (from `packages/config/pressure.yaml`): `calm ≤10`, `normal ≤25`, `elevated ≤40`, `stressed ≤55`, |
| 11 | 11 | `high ≤70`, `severe ≤85`, `extreme ≤100`. Every `level` field is one of these ids; `level_label` is its label. |
modified
infra/edge/Caddyfile
+16 −5
@@ -14,8 +14,6 @@ | ||
| 14 | 14 | } |
| 15 | 15 | |
| 16 | 16 | :8350 { |
| 17 | − encode zstd gzip | |
| 18 | − | |
| 19 | 17 | header { |
| 20 | 18 | -Server |
| 21 | 19 | X-Content-Type-Options nosniff |
@@ -26,9 +24,9 @@ | ||
| 26 | 24 | defer |
| 27 | 25 | } |
| 28 | 26 | |
| 29 | − # probe ingestion (signed, gzip bodies) and the public API incl. SSE (Caddy streams text/event-stream) | |
| 30 | − @backend path /ingest/* /api/* | |
| 31 | − handle @backend { | |
| 27 | + # Server-Sent Events: never compressed or buffered | |
| 28 | + @live path /api/v1/live | |
| 29 | + handle @live { | |
| 32 | 30 | reverse_proxy api:8352 { |
| 33 | 31 | flush_interval -1 |
| 34 | 32 | transport http { |
@@ -39,8 +37,21 @@ | ||
| 39 | 37 | } |
| 40 | 38 | } |
| 41 | 39 | |
| 40 | + # probe ingestion (signed, gzip bodies) and the public API | |
| 41 | + @backend path /ingest/* /api/* | |
| 42 | + handle @backend { | |
| 43 | + encode zstd gzip | |
| 44 | + reverse_proxy api:8352 { | |
| 45 | + transport http { | |
| 46 | + dial_timeout 5s | |
| 47 | + } | |
| 48 | + header_up X-Forwarded-Proto https | |
| 49 | + } | |
| 50 | + } | |
| 51 | + | |
| 42 | 52 | # everything else → Next.js |
| 43 | 53 | handle { |
| 54 | + encode zstd gzip | |
| 44 | 55 | reverse_proxy web:8351 { |
| 45 | 56 | header_up X-Forwarded-Proto https |
| 46 | 57 | } |
| 47 | 58 | |