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%

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>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent 3ee6dd2

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&apos;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