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%

InternetPressure.io — backend (ingest, engine, BGP, corroboration, API, SSE), Go probe agent, infra, docs

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

151 changed files +20,001 −0

added .gitignore +36 −0
@@ -0,0 +1,36 @@
1 +# secrets
2 +.env
3 +.env.*
4 +!deploy/.env.example
5 +deploy/.env
6 +deploy/probe-keys/
7 +
8 +# python
9 +.venv/
10 +__pycache__/
11 +*.pyc
12 +.pytest_cache/
13 +.ruff_cache/
14 +*.egg-info/
15 +
16 +# node
17 +node_modules/
18 +apps/web/.next/
19 +apps/web/out/
20 +apps/web/next-env.d.ts
21 +*.tsbuildinfo
22 +apps/web/qa/screens/
23 +
24 +# go
25 +services/probe-agent/dist/
26 +services/probe-agent/*.test
27 +
28 +# data caches
29 +data/asn/*.gz
30 +data/asn/*.tmp
31 +infra/releases/
32 +
33 +# misc
34 +.DS_Store
35 +tmp/
36 +logs/
added CLAUDE.md +52 −0
@@ -0,0 +1,52 @@
1 +# InternetPressure.io — repository guide
2 +
3 +**The real-time pressure gauge for the Internet.** Full product spec: `docs/SPEC.md` (authoritative). Architecture
4 +decisions: `docs/ARCHITECTURE.md`. Contracts: `docs/API.md` (public + admin API), `docs/PROBE-PROTOCOL.md`
5 +(probe ↔ ingestion). Deployment: `docs/DEPLOY.md`.
6 +
7 +## Layout
8 +
9 +```
10 +apps/web Next 16 + React 19 + Tailwind 4 + MapLibre + ECharts (port 8351)
11 +apps/api Python 3.12 package `internetpressure` — FastAPI API/ingest, pressure engine, event engine,
12 + BGP ingestor, scheduler, corroboration connectors (api on port 8352)
13 +services/probe-agent Go probe agent (single static binary, darwin/arm64 + linux/amd64)
14 +packages/config pressure.yaml — weights, levels, engine/scheduler parameters (hot-reloaded, editable in /admin)
15 +data/regions.yaml region model · data/targets/targets.yaml target registry seed · data/seed/*.yaml services, probes
16 +infra/ Docker Compose (compose.yml), edge Caddy, ClickHouse/Postgres init, systemd/launchd units for probes
17 +deploy/bin deploy.sh (BHS64b), probes.sh (build + push probe binaries), lib.sh
18 +docs/ SPEC, ARCHITECTURE, API, PROBE-PROTOCOL, DEPLOY, RUNBOOK
19 +tests/ pytest (scoring, normalisation, events, ingest, api)
20 +```
21 +
22 +## Non-negotiable rules (from the spec)
23 +
24 +1. **No fake real-time.** Never animate random numbers, never synthesise events. If a value did not change, the UI does
25 + not move. Every visible movement originates from a real measurement or BGP message.
26 +2. **Self-exclusion.** Our own outage must never inflate the index: the engine freezes and flags `internal_status`
27 + when probes/BGP/stores are unhealthy. See `internetpressure/engine/health.py`.
28 +3. **Explainable.** Every score decomposes into components → signals → (scope, current, baseline, robust z, contribution).
29 +4. **Config-driven weights.** Weights/levels/thresholds live in `packages/config/pressure.yaml` (or the Postgres
30 + `config` override) — never in code.
31 +5. **Baseline-relative.** Robust z = (x − rolling median) / MAD, clipped. Absolute thresholds only where unavoidable
32 + (documented in `docs/ARCHITECTURE.md`).
33 +6. **Ethical measurement.** Ordinary lightweight client traffic only: no scanning, no auth bypass, no bursts.
34 +7. **UTC everywhere** in storage and APIs; the browser localises.
35 +8. **No secrets in Git.** `.env` files are ignored; `deploy/.env.example` documents every variable.
36 +
37 +## Dev quick start
38 +
39 +```bash
40 +docker compose -f infra/compose.yml --profile dev up -d clickhouse postgres redis # stores only
41 +cd apps/api && uv sync && uv run ip migrate && uv run ip seed && uv run ip api # :8352
42 +uv run ip engine # pressure engine loop · uv run ip bgp # RIS Live ingestor
43 +cd apps/web && pnpm install && pnpm dev # :8351 (rewrites /api → :8352)
44 +cd services/probe-agent && make build && ./dist/ip-probe-darwin-arm64 --config probe.dev.yaml
45 +pytest -q # from apps/api
46 +```
47 +
48 +## Production
49 +
50 +BHS64b (OVH Beauharnois, ubuntu@51.161.112.66) runs `infra/compose.yml` (edge Caddy :8350 bound to WireGuard
51 +10.67.0.61). Public route: BHS64 gateway `https://www.internetpressure.io → 10.67.0.61:8350`. Probes run as
52 +launchd (macOS) / systemd (Linux) services on cluster nodes. `deploy/bin/deploy.sh` and `deploy/bin/probes.sh`.
added README.md +21 −0
@@ -0,0 +1,21 @@
1 +# InternetPressure.io
2 +
3 +**The real-time pressure gauge for the Internet.** An independent observability network (our own probes on three
4 +continents + RIPE RIS Live) synthesised into a single continuously changing index, *Global Internet Pressure* (0–100),
5 +with regional, country, ASN and service views, incident detection, Pressure Fronts and full explainability.
6 +
7 +Live: https://www.internetpressure.io · API: `/api/v1/*` (see `docs/API.md`) · Methodology: `/methodology`.
8 +
9 +| | |
10 +|---|---|
11 +| `apps/web` | Next 16 front-end (homepage = the instrument, map, regions, ASNs, services, routes, incidents, history, admin) |
12 +| `apps/api` | Python package `internetpressure`: ingestion API, pressure engine, event engine, BGP ingestor, corroboration connectors, public/admin API, SSE |
13 +| `services/probe-agent` | Go probe agent (HTTP/DNS/ICMP/traceroute, signed gzip batches, spool, self-update) |
14 +| `packages/config/pressure.yaml` | weights, levels, engine & scheduler parameters |
15 +| `data/` | region model, target registry seed (326 targets), services, probes |
16 +| `infra/` · `deploy/` | Docker Compose stack, edge Caddy, deploy & probe scripts |
17 +| `docs/` | SPEC (product), ARCHITECTURE, API, PROBE-PROTOCOL, DEPLOY |
18 +
19 +See `CLAUDE.md` for the repository guide and the non-negotiable rules (no fake real-time, self-exclusion, explainability).
20 +
21 +Independent observatory by Simon-Pierre Boucher · contact@spboucher.ai · hosted on MacLustr (www.maclustr.io).
added apps/api/pyproject.toml +43 −0
@@ -0,0 +1,43 @@
1 +[project]
2 +name = "internetpressure"
3 +version = "0.1.0"
4 +description = "InternetPressure.io — ingestion API, pressure engine, event engine, BGP ingestor and public API"
5 +requires-python = ">=3.12"
6 +dependencies = [
7 + "fastapi>=0.115",
8 + "uvicorn[standard]>=0.30",
9 + "pydantic>=2.8",
10 + "pydantic-settings>=2.4",
11 + "asyncpg>=0.30",
12 + "redis>=5.1",
13 + "httpx>=0.27",
14 + "websockets>=13",
15 + "orjson>=3.10",
16 + "typer>=0.12",
17 + "pyyaml>=6",
18 + "python-ulid>=2.7",
19 + "python-slugify>=8",
20 + "feedparser>=6.0",
21 +]
22 +
23 +[project.optional-dependencies]
24 +dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff>=0.6", "respx>=0.21"]
25 +
26 +[project.scripts]
27 +ip = "internetpressure.cli:app"
28 +
29 +[build-system]
30 +requires = ["hatchling"]
31 +build-backend = "hatchling.build"
32 +
33 +[tool.hatch.build.targets.wheel]
34 +packages = ["src/internetpressure"]
35 +
36 +[tool.ruff]
37 +line-length = 120
38 +target-version = "py312"
39 +
40 +[tool.pytest.ini_options]
41 +testpaths = ["tests"]
42 +asyncio_mode = "auto"
43 +markers = ["live: needs real ClickHouse/Postgres/Redis (skipped unless -m live)"]
added apps/api/src/internetpressure/__init__.py +3 −0
@@ -0,0 +1,3 @@
1 +"""InternetPressure.io backend — the real-time pressure gauge for the Internet."""
2 +
3 +__version__ = "0.1.0"
added apps/api/src/internetpressure/api/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""HTTP API: public /api/v1, SSE /api/v1/live, admin /api/admin, probe ingestion /ingest/v1."""
added apps/api/src/internetpressure/api/admin.py +353 −0
@@ -0,0 +1,353 @@
1 +"""Admin API (X-IP-Admin-Token). Registry management, scoring config, diagnostics, raw explorer, review, replay."""
2 +
3 +from __future__ import annotations
4 +
5 +import datetime as dt
6 +from typing import Any
7 +
8 +from fastapi import APIRouter, Depends, Header, HTTPException, Query
9 +from pydantic import BaseModel, Field
10 +
11 +from ..config import ConfigError
12 +from ..db import ch, pg, rds
13 +from ..engine.events import incident_dict
14 +from ..engine.replay import replay_range
15 +from ..ingest.router import invalidate_probe_cache
16 +from ..ingest.writer import writer
17 +from ..registry import (bump_config_version, delete_target, get_probe, list_probes, list_services, load_config,
18 + rotate_probe_key, save_config_override, upsert_probe, upsert_target)
19 +from ..settings import get_settings
20 +from ..util import dumps_str, iso, loads, parse_ts, r1, utcnow
21 +from .public import _probes_public
22 +
23 +router = APIRouter(prefix="/api/admin", tags=["admin"])
24 +
25 +
26 +async def require_admin(x_ip_admin_token: str | None = Header(default=None)) -> str:
27 + token = get_settings().admin_token
28 + if not token or not x_ip_admin_token or x_ip_admin_token != token:
29 + raise HTTPException(401, {"error": "unauthorized"})
30 + return "admin"
31 +
32 +
33 +async def _audit(actor: str, action: str, detail: Any) -> None:
34 + await pg.execute("INSERT INTO audit_log(actor,action,detail) VALUES($1,$2,$3::jsonb)", actor, action, dumps_str(detail))
35 +
36 +
37 +# ── overview ────────────────────────────────────────────────────────────────────────────────────────────────────────
38 +
39 +@router.get("/overview")
40 +async def overview(_: str = Depends(require_admin)) -> dict[str, Any]:
41 + now = utcnow()
42 + mins = [(now - dt.timedelta(minutes=i)).strftime("%Y%m%d%H%M") for i in range(1, 6)]
43 + probes = await _probes_public(await list_probes())
44 + hrows = await ch.query("""SELECT probe_id, argMax(clock_offset_ms, ts) AS clock, argMax(buffered, ts) AS buffered, argMax(spool_bytes, ts) AS spool,
45 + argMax(agent_version, ts) AS version, max(ts) AS last FROM probe_health WHERE ts >= now() - INTERVAL 24 HOUR GROUP BY probe_id""")
46 + health = {r["probe_id"]: r for r in hrows}
47 + erows = await ch.query("""SELECT probe_id, avg(ok = 0) AS err, count() AS n FROM measurements WHERE ts >= now() - INTERVAL 1 HOUR GROUP BY probe_id""")
48 + errs = {r["probe_id"]: r for r in erows}
49 + cfg = await load_config()
50 + expected_per_h = 3600 / int(cfg.scheduler.get("tiers", {}).get(2, 45)) * 0.5 # rough per-target expectation
51 + for p in probes:
52 + h = health.get(p["probe_id"], {})
53 + e = errs.get(p["probe_id"], {})
54 + p["health"] = {"uptime_24h": p["uptime_24h"], "clock_offset_ms": h.get("clock"), "buffered": h.get("buffered"),
55 + "spool_bytes": h.get("spool"), "version": h.get("version") or p.get("version"), "last_health": h.get("last"),
56 + "error_rate_1h": r1((e.get("err") or 0) * 100) if e else None,
57 + "missing_ratio_1h": None if not e else r1(max(0.0, 1 - (e.get("n") or 0) / max(1.0, expected_per_h * 300)))}
58 + tables = await ch.query("""SELECT table AS name, sum(rows) AS rows, sum(bytes_on_disk) AS bytes, min(min_time) AS oldest, max(max_time) AS newest
59 + FROM system.parts WHERE database = currentDatabase() AND active GROUP BY table ORDER BY bytes DESC""")
60 + pgsize = await pg.fetchval("SELECT pg_database_size(current_database())")
61 + rinfo = await rds.r().info("memory")
62 + keys = await rds.r().dbsize()
63 + bgp = await rds.get_json("ip:bgp:rate") or {}
64 + routing = await rds.get_json("ip:live:routing") or {}
65 + runs = await ch.query("""SELECT quantileTDigest(0.5)(cycle_ms) AS p50, max(cycle_ms) AS mx, count() AS n, countIf(error != '') AS errors
66 + FROM engine_runs WHERE ts >= now() - INTERVAL 1 HOUR""")
67 + st = await rds.get_json("ip:live:status") or {}
68 + vendors = [dict(r) for r in await pg.fetch("SELECT service_slug, ok, error, indicator, incidents, checked_at FROM vendor_status ORDER BY service_slug")]
69 + services = {s["slug"]: s for s in await list_services()}
70 + return {
71 + "ts": iso(now), "probes": probes,
72 + "ingest": {"batches_per_min": (await rds.sum_minutes("batch", mins)) / 5, "measurements_per_min": (await rds.sum_minutes("meas", mins)) / 5,
73 + "rejected_per_min": (await rds.sum_minutes("rej", mins)) / 5, "last_batch": st.get("last_batch"),
74 + "writer": {"pending": writer.pending(), "inserted_total": writer.inserted_total, "dropped_total": writer.dropped_total,
75 + "last_error": writer.last_error, "inserts_per_s": r1(writer.inserts_per_s())}},
76 + "stores": {"clickhouse": {"ok": await ch.healthy(), "inserts_per_s": r1(writer.inserts_per_s()), "tables": tables},
77 + "postgres": {"ok": await pg.healthy(), "size_bytes": pgsize},
78 + "redis": {"ok": await rds.healthy(), "used_memory_bytes": rinfo.get("used_memory"), "keys": keys}},
79 + "bgp": {"collectors": routing.get("collectors") or [], "messages_per_s": bgp.get("updates_per_s"), "fresh": (st.get("bgp") or {}).get("fresh"),
80 + "reconnects_24h": bgp.get("reconnects"), "last_message": bgp.get("ts")},
81 + "engine": {"last_run": (await rds.r().get("ip:engine:last_run") or b"").decode() or None,
82 + "cycle_ms_p50": r1(runs[0]["p50"]) if runs and runs[0].get("n") else None, "cycle_ms_max": runs[0]["mx"] if runs and runs[0].get("n") else None,
83 + "runs_1h": runs[0]["n"] if runs else 0, "errors_1h": runs[0]["errors"] if runs else 0,
84 + "internal_status": st.get("internal_status"), "reasons": st.get("reasons", []), "excluded_probes": (st.get("probes") or {}).get("excluded", []),
85 + "baselines": await rds.get_json("ip:engine:baselines_meta")},
86 + "corroboration": [{"id": v["service_slug"], "name": services.get(v["service_slug"], {}).get("name"), "ok": v["ok"], "error": v["error"],
87 + "indicator": v["indicator"], "incidents": v["incidents"], "last_fetch": iso(v["checked_at"])} for v in vendors],
88 + }
89 +
90 +
91 +# ── targets ─────────────────────────────────────────────────────────────────────────────────────────────────────────
92 +
93 +class TargetIn(BaseModel):
94 + target_id: str = Field(min_length=2, max_length=80, pattern=r"^[a-z0-9][a-z0-9\-\.]*$")
95 + name: str
96 + hostname: str
97 + url: str | None = None
98 + ip: str | None = None
99 + port: int = 443
100 + category: str
101 + provider: str | None = None
102 + service_id: str | None = None
103 + country: str | None = None
104 + region: str | None = None
105 + importance: int = Field(3, ge=1, le=5)
106 + tier: int = Field(2, ge=1, le=3)
107 + checks: list[str] = ["http", "dns", "ping"]
108 + traceroute: bool = False
109 + enabled: bool = True
110 +
111 +
112 +@router.get("/targets")
113 +async def admin_targets(_: str = Depends(require_admin)) -> dict[str, Any]:
114 + from ..registry import list_targets
115 +
116 + return {"targets": await list_targets()}
117 +
118 +
119 +@router.post("/targets", status_code=201)
120 +async def create_target(t: TargetIn, actor: str = Depends(require_admin)) -> dict[str, Any]:
121 + row = await upsert_target(t.model_dump())
122 + await _audit(actor, "target.upsert", row)
123 + await bump_config_version()
124 + return row
125 +
126 +
127 +@router.patch("/targets/{target_id}")
128 +async def patch_target(target_id: str, patch: dict[str, Any], actor: str = Depends(require_admin)) -> dict[str, Any]:
129 + from ..registry import get_target
130 +
131 + cur = await get_target(target_id)
132 + if not cur:
133 + raise HTTPException(404, {"error": "not_found"})
134 + cur.update({k: v for k, v in patch.items() if k in TargetIn.model_fields and k != "target_id"})
135 + row = await upsert_target(cur)
136 + await _audit(actor, "target.patch", {"target_id": target_id, "patch": patch})
137 + await bump_config_version()
138 + return row
139 +
140 +
141 +@router.delete("/targets/{target_id}", status_code=204)
142 +async def remove_target(target_id: str, actor: str = Depends(require_admin)) -> None:
143 + if not await delete_target(target_id):
144 + raise HTTPException(404, {"error": "not_found"})
145 + await _audit(actor, "target.delete", {"target_id": target_id})
146 + await bump_config_version()
147 +
148 +
149 +# ── probes ──────────────────────────────────────────────────────────────────────────────────────────────────────────
150 +
151 +class ProbeIn(BaseModel):
152 + probe_id: str = Field(min_length=3, max_length=40, pattern=r"^[a-z]{2}-[a-z0-9]+-[0-9]{2}$")
153 + name: str
154 + region: str
155 + country: str | None = None
156 + city: str | None = None
157 + provider: str | None = None
158 + asn: int | None = None
159 + lat: float | None = None
160 + lon: float | None = None
161 + node: str | None = None
162 +
163 +
164 +@router.get("/probes")
165 +async def admin_probes(_: str = Depends(require_admin)) -> dict[str, Any]:
166 + return {"probes": await _probes_public(await list_probes())}
167 +
168 +
169 +@router.post("/probes", status_code=201)
170 +async def create_probe(p: ProbeIn, actor: str = Depends(require_admin)) -> dict[str, Any]:
171 + if await get_probe(p.probe_id):
172 + raise HTTPException(409, {"error": "exists"})
173 + row = await upsert_probe(p.model_dump())
174 + await _audit(actor, "probe.create", {"probe_id": p.probe_id})
175 + invalidate_probe_cache(p.probe_id)
176 + return row # includes key once
177 +
178 +
179 +@router.patch("/probes/{probe_id}")
180 +async def patch_probe(probe_id: str, patch: dict[str, Any], actor: str = Depends(require_admin)) -> dict[str, Any]:
181 + cur = await get_probe(probe_id, with_key=True)
182 + if not cur:
183 + raise HTTPException(404, {"error": "not_found"})
184 + if "enabled" in patch:
185 + await pg.execute("UPDATE probes SET enabled=$2, updated_at=now() WHERE probe_id=$1", probe_id, bool(patch["enabled"]))
186 + meta = {k: v for k, v in patch.items() if k in ProbeIn.model_fields and k != "probe_id"}
187 + if meta:
188 + cur.update(meta)
189 + await upsert_probe(cur, key=cur["key"])
190 + await _audit(actor, "probe.patch", {"probe_id": probe_id, "patch": patch})
191 + invalidate_probe_cache(probe_id)
192 + row = await get_probe(probe_id)
193 + return row or {}
194 +
195 +
196 +@router.post("/probes/{probe_id}/rotate-key")
197 +async def rotate_key(probe_id: str, actor: str = Depends(require_admin)) -> dict[str, Any]:
198 + if not await get_probe(probe_id):
199 + raise HTTPException(404, {"error": "not_found"})
200 + key = await rotate_probe_key(probe_id)
201 + await _audit(actor, "probe.rotate_key", {"probe_id": probe_id})
202 + invalidate_probe_cache(probe_id)
203 + return {"probe_id": probe_id, "key": key}
204 +
205 +
206 +# ── config ──────────────────────────────────────────────────────────────────────────────────────────────────────────
207 +
208 +@router.get("/config")
209 +async def get_config(_: str = Depends(require_admin)) -> dict[str, Any]:
210 + cfg = await load_config()
211 + return cfg.public_dict() | {"raw": cfg.raw}
212 +
213 +
214 +@router.put("/config")
215 +async def put_config(value: dict[str, Any], actor: str = Depends(require_admin)) -> dict[str, Any]:
216 + try:
217 + cfg = await save_config_override(value, actor)
218 + except ConfigError as exc:
219 + raise HTTPException(422, {"error": "validation", "detail": str(exc)}) from exc
220 + return cfg.public_dict()
221 +
222 +
223 +@router.delete("/config", status_code=204)
224 +async def reset_config(actor: str = Depends(require_admin)) -> None:
225 + await pg.execute("DELETE FROM config WHERE key='pressure'")
226 + await _audit(actor, "config.reset", {})
227 +
228 +
229 +# ── diagnostics ─────────────────────────────────────────────────────────────────────────────────────────────────────
230 +
231 +@router.get("/baselines")
232 +async def baselines(signal_id: str = "ttfb_z", scope_type: str = "global", scope_id: str = "global", hours: int = Query(24, le=168),
233 + _: str = Depends(require_admin)) -> dict[str, Any]:
234 + rows = await ch.query(f"""
235 + SELECT ts, current AS value, baseline AS median, mad, robust_z AS z, stress, contribution, samples FROM signal_features
236 + WHERE signal_id = {{sid:String}} AND scope_type = {{st:String}} AND scope_id = {{sc:String}} AND ts >= now() - INTERVAL {hours} HOUR ORDER BY ts
237 + """, params={"sid": signal_id, "st": scope_type, "sc": scope_id})
238 + meta = await rds.get_json("ip:engine:baselines_meta") or {}
239 + return {"signal_id": signal_id, "scope_type": scope_type, "scope_id": scope_id, "points": rows,
240 + "samples": rows[-1]["samples"] if rows else 0, "baseline_days": meta.get("history_days")}
241 +
242 +
243 +RAW_TABLES = {"measurements", "traceroutes", "bgp_events", "bgp_stats_10s", "bgp_origin_1m", "pressure_history", "signal_features",
244 + "probe_health", "engine_runs"}
245 +
246 +
247 +@router.get("/raw")
248 +async def raw(table: str, probe_id: str | None = None, target_id: str | None = None, limit: int = Query(200, le=2000),
249 + _: str = Depends(require_admin)) -> dict[str, Any]:
250 + if table not in RAW_TABLES:
251 + raise HTTPException(422, {"error": "validation", "detail": f"table must be one of {sorted(RAW_TABLES)}"})
252 + conds = []
253 + params: dict[str, Any] = {"limit": limit}
254 + if probe_id and table in ("measurements", "traceroutes", "probe_health"):
255 + conds.append("probe_id = {pid:String}")
256 + params["pid"] = probe_id
257 + if target_id and table in ("measurements", "traceroutes"):
258 + conds.append("target_id = {tid:String}")
259 + params["tid"] = target_id
260 + where = ("WHERE " + " AND ".join(conds)) if conds else ""
261 + rows = await ch.query(f"SELECT * FROM {table} {where} ORDER BY ts DESC LIMIT {{limit:UInt32}}", params=params)
262 + cols = list(rows[0].keys()) if rows else []
263 + return {"table": table, "columns": cols, "rows": [[r.get(c) for c in cols] for r in rows]}
264 +
265 +
266 +# ── incidents review / annotations / replay / boost ─────────────────────────────────────────────────────────────────
267 +
268 +@router.get("/incidents")
269 +async def admin_incidents(status: str | None = None, _: str = Depends(require_admin)) -> dict[str, Any]:
270 + rows = await pg.fetch("SELECT * FROM events WHERE ($1::text IS NULL OR status=$1) ORDER BY started_at DESC LIMIT 200", status)
271 + return {"incidents": [incident_dict(dict(r)) | {"review_note": r["review_note"]} for r in rows]}
272 +
273 +
274 +class ReviewIn(BaseModel):
275 + review: str = Field(pattern=r"^(confirmed|dismissed|unreviewed)$")
276 + note: str | None = None
277 +
278 +
279 +@router.patch("/incidents/{event_id}")
280 +async def review_incident(event_id: str, body: ReviewIn, actor: str = Depends(require_admin)) -> dict[str, Any]:
281 + r = await pg.fetchrow("UPDATE events SET review=$2, review_note=$3 WHERE event_id=$1 OR slug=$1 RETURNING *", event_id, body.review, body.note)
282 + if not r:
283 + raise HTTPException(404, {"error": "not_found"})
284 + await _audit(actor, "incident.review", {"event_id": event_id, **body.model_dump()})
285 + return incident_dict(dict(r)) | {"review_note": r["review_note"]}
286 +
287 +
288 +class AnnotationIn(BaseModel):
289 + ts: str
290 + scope_type: str
291 + scope_id: str | None = None
292 + text: str = Field(min_length=1, max_length=2000)
293 +
294 +
295 +@router.post("/annotations", status_code=201)
296 +async def add_annotation(a: AnnotationIn, actor: str = Depends(require_admin)) -> dict[str, Any]:
297 + ts = parse_ts(a.ts)
298 + if not ts:
299 + raise HTTPException(422, {"error": "validation", "detail": "bad ts"})
300 + r = await pg.fetchrow("INSERT INTO annotations(ts,scope_type,scope_id,text,author) VALUES($1,$2,$3,$4,$5) RETURNING *", ts, a.scope_type, a.scope_id, a.text, actor)
301 + return {"id": r["id"], "ts": iso(r["ts"]), "scope_type": r["scope_type"], "scope_id": r["scope_id"], "text": r["text"], "author": r["author"]}
302 +
303 +
304 +@router.get("/annotations")
305 +async def annotations(_: str = Depends(require_admin)) -> dict[str, Any]:
306 + rows = await pg.fetch("SELECT * FROM annotations ORDER BY ts DESC LIMIT 500")
307 + return {"annotations": [{"id": r["id"], "ts": iso(r["ts"]), "scope_type": r["scope_type"], "scope_id": r["scope_id"], "text": r["text"], "author": r["author"]} for r in rows]}
308 +
309 +
310 +class ReplayIn(BaseModel):
311 + from_: str = Field(alias="from")
312 + to: str
313 + weights: dict[str, float] | None = None
314 +
315 + model_config = {"populate_by_name": True}
316 +
317 +
318 +@router.post("/replay")
319 +async def replay(body: ReplayIn, _: str = Depends(require_admin)) -> dict[str, Any]:
320 + try:
321 + rows = await replay_range(body.from_, body.to, body.weights)
322 + except ValueError as exc:
323 + raise HTTPException(422, {"error": "validation", "detail": str(exc)}) from exc
324 + step = None
325 + if len(rows) >= 2:
326 + a, b = parse_ts(rows[0]["ts"]), parse_ts(rows[1]["ts"])
327 + step = int((b - a).total_seconds()) if a and b else None
328 + return {"step_seconds": step, "points": rows}
329 +
330 +
331 +class BoostIn(BaseModel):
332 + targets: list[str]
333 + factor: float = Field(0.5, gt=0.05, le=1.0)
334 + seconds: int = Field(900, ge=60, le=7200)
335 +
336 +
337 +@router.post("/boost")
338 +async def boost(b: BoostIn, actor: str = Depends(require_admin)) -> dict[str, Any]:
339 + until = utcnow() + dt.timedelta(seconds=b.seconds)
340 + payload = {"targets": b.targets[:100], "factor": b.factor, "until": iso(until), "manual": True}
341 + await rds.set_json("ip:boost", payload, ex=b.seconds + 60)
342 + await bump_config_version()
343 + await _audit(actor, "boost", payload)
344 + return payload
345 +
346 +
347 +@router.get("/audit")
348 +async def audit(_: str = Depends(require_admin)) -> dict[str, Any]:
349 + rows = await pg.fetch("SELECT * FROM audit_log ORDER BY ts DESC LIMIT 200")
350 + return {"entries": [{"ts": iso(r["ts"]), "actor": r["actor"], "action": r["action"], "detail": r["detail"]} for r in rows]}
351 +
352 +
353 +_ = loads # keep import (used by future raw decoding)
added apps/api/src/internetpressure/api/app.py +108 −0
@@ -0,0 +1,108 @@
1 +"""FastAPI application factory."""
2 +
3 +from __future__ import annotations
4 +
5 +import logging
6 +import time
7 +from collections import defaultdict, deque
8 +from contextlib import asynccontextmanager
9 +
10 +from fastapi import FastAPI, HTTPException, Request
11 +from fastapi.exceptions import RequestValidationError
12 +from fastapi.responses import ORJSONResponse
13 +from starlette.middleware.base import BaseHTTPMiddleware
14 +
15 +from .. import __version__
16 +from ..asn import asndb
17 +from ..db import ch, pg, rds
18 +from ..ingest.router import router as ingest_router
19 +from ..ingest.writer import writer
20 +from ..settings import get_settings
21 +from .admin import router as admin_router
22 +from .live import router as live_router
23 +from .public import router as public_router
24 +
25 +log = logging.getLogger("ip.api")
26 +
27 +
28 +class RateLimit(BaseHTTPMiddleware):
29 + """Fixed-window per-IP limit for the public API (SSE handled separately). Ingest and admin are exempt."""
30 +
31 + def __init__(self, app, per_min: int) -> None: # type: ignore[no-untyped-def]
32 + super().__init__(app)
33 + self.per_min = per_min
34 + self.hits: dict[str, deque[float]] = defaultdict(deque)
35 +
36 + async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
37 + path = request.url.path
38 + if path.startswith("/api/v1/") and not path.startswith("/api/v1/live"):
39 + ip = _client_ip(request)
40 + now = time.time()
41 + q = self.hits[ip]
42 + while q and q[0] < now - 60:
43 + q.popleft()
44 + if len(q) >= self.per_min:
45 + return ORJSONResponse({"error": "rate_limited"}, status_code=429, headers={"Retry-After": "30"})
46 + q.append(now)
47 + if len(self.hits) > 50_000: # crude memory bound
48 + self.hits.clear()
49 + resp = await call_next(request)
50 + if path.startswith("/api/") and "cache-control" not in resp.headers:
51 + resp.headers["Cache-Control"] = "no-store"
52 + resp.headers.setdefault("X-Content-Type-Options", "nosniff")
53 + return resp
54 +
55 +
56 +def _client_ip(request: Request) -> str:
57 + if get_settings().trust_proxy:
58 + xff = request.headers.get("x-forwarded-for")
59 + if xff:
60 + return xff.split(",")[0].strip()
61 + return request.client.host if request.client else "?"
62 +
63 +
64 +@asynccontextmanager
65 +async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
66 + try:
67 + await pg.migrate()
68 + await ch.migrate()
69 + except Exception as exc: # noqa: BLE001
70 + log.warning("migrations skipped: %s", exc)
71 + await writer.start()
72 + try:
73 + asndb.load() or await asndb.refresh()
74 + except Exception as exc: # noqa: BLE001
75 + log.warning("asn db unavailable: %s", exc)
76 + log.info("api ready (v%s)", __version__)
77 + yield
78 + await writer.stop()
79 + await pg.close()
80 + await ch.close()
81 + await rds.close()
82 +
83 +
84 +def create_app() -> FastAPI:
85 + s = get_settings()
86 + app = FastAPI(title="InternetPressure.io API", version=__version__, default_response_class=ORJSONResponse,
87 + lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json", redoc_url=None)
88 + app.add_middleware(RateLimit, per_min=s.public_rate_limit_per_min)
89 +
90 + @app.exception_handler(HTTPException)
91 + async def _http_exc(_: Request, exc: HTTPException): # type: ignore[no-untyped-def]
92 + # docs/API.md: errors are flat objects like {"error": "not_found"}
93 + body = exc.detail if isinstance(exc.detail, dict) else {"error": str(exc.detail)}
94 + return ORJSONResponse(body, status_code=exc.status_code, headers=exc.headers)
95 +
96 + @app.exception_handler(RequestValidationError)
97 + async def _val_exc(_: Request, exc: RequestValidationError): # type: ignore[no-untyped-def]
98 + return ORJSONResponse({"error": "validation", "detail": exc.errors()}, status_code=422)
99 + app.include_router(public_router)
100 + app.include_router(live_router)
101 + app.include_router(admin_router)
102 + app.include_router(ingest_router)
103 +
104 + @app.get("/api/v1/health", include_in_schema=False)
105 + async def health() -> dict:
106 + return {"ok": True, "version": __version__}
107 +
108 + return app
added apps/api/src/internetpressure/api/live.py +93 −0
@@ -0,0 +1,93 @@
1 +"""GET /api/v1/live — Server-Sent Events fed by the Redis pub/sub channel (nothing synthetic: the engine publishes,
2 +we forward). A `snapshot` is sent on connect; a `: ping` comment every 15 s keeps proxies happy."""
3 +
4 +from __future__ import annotations
5 +
6 +import asyncio
7 +import logging
8 +import time
9 +from collections import defaultdict
10 +
11 +from fastapi import APIRouter, Request
12 +from starlette.responses import StreamingResponse
13 +
14 +from ..db import rds
15 +from ..settings import get_settings
16 +from ..util import dumps, loads
17 +from .public import ticker_payload
18 +
19 +log = logging.getLogger("ip.live")
20 +router = APIRouter(prefix="/api/v1", tags=["live"])
21 +_conns: dict[str, int] = defaultdict(int)
22 +
23 +
24 +def _fmt(event: str, data: bytes, eid: int) -> bytes:
25 + return b"id: %d\nevent: %s\ndata: %s\n\n" % (eid, event.encode(), data)
26 +
27 +
28 +@router.get("/live")
29 +async def live(request: Request) -> StreamingResponse:
30 + s = get_settings()
31 + ip = request.headers.get("x-forwarded-for", request.client.host if request.client else "?").split(",")[0].strip()
32 + if _conns[ip] >= s.sse_max_per_ip:
33 + from fastapi.responses import ORJSONResponse
34 +
35 + return ORJSONResponse({"error": "too_many_streams"}, status_code=429, headers={"Retry-After": "10"}) # type: ignore[return-value]
36 +
37 + async def gen(): # type: ignore[no-untyped-def]
38 + _conns[ip] += 1
39 + eid = int(time.time() * 1000)
40 + pubsub = rds.r().pubsub()
41 + try:
42 + await pubsub.subscribe(rds.CHANNEL)
43 + yield b"retry: 5000\n\n"
44 + snap = {
45 + "global": await rds.get_json("ip:live:global"), "ticker": await ticker_payload(),
46 + "regions": await rds.get_json("ip:live:regions", []), "fronts": await rds.get_json("ip:live:fronts", []),
47 + "incidents": await rds.get_json("ip:live:incidents", []), "status": await rds.get_json("ip:live:status"),
48 + }
49 + yield _fmt("snapshot", dumps(snap), eid)
50 + last_ping = time.time()
51 + last_ticker = time.time()
52 + while True:
53 + if await request.is_disconnected():
54 + break
55 + msg = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
56 + if msg and msg.get("type") == "message":
57 + try:
58 + payload = loads(msg["data"])
59 + eid += 1
60 + yield _fmt(payload["event"], dumps(payload["data"]), eid)
61 + except Exception as exc: # noqa: BLE001
62 + log.debug("bad pubsub payload: %s", exc)
63 + now = time.time()
64 + if now - last_ticker >= 5:
65 + last_ticker = now
66 + eid += 1
67 + yield _fmt("ticker", dumps(await ticker_payload()), eid)
68 + bgp = await rds.get_json("ip:live:routing")
69 + if bgp:
70 + eid += 1
71 + yield _fmt("bgp_stats", dumps({k: bgp.get(k) for k in ("ts", "updates_per_s", "announcements_per_s",
72 + "withdrawals_per_s", "ratio", "fresh")}), eid)
73 + st = await rds.get_json("ip:live:status") or {}
74 + eid += 1
75 + yield _fmt("probe_stats", dumps({"ts": st.get("ts"), "probes_active": (st.get("probes") or {}).get("fresh"),
76 + "probes_total": (st.get("probes") or {}).get("total"),
77 + "measurements_per_s": (await ticker_payload()).get("measurements_per_s"),
78 + "excluded": (st.get("probes") or {}).get("excluded", [])}), eid)
79 + if now - last_ping >= 15:
80 + last_ping = now
81 + yield b": ping\n\n"
82 + except asyncio.CancelledError:
83 + pass
84 + finally:
85 + _conns[ip] = max(0, _conns[ip] - 1)
86 + try:
87 + await pubsub.unsubscribe(rds.CHANNEL)
88 + await pubsub.aclose()
89 + except Exception: # noqa: BLE001
90 + pass
91 +
92 + return StreamingResponse(gen(), media_type="text/event-stream",
93 + headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no", "Connection": "keep-alive"})
added apps/api/src/internetpressure/api/public.py +756 −0
@@ -0,0 +1,756 @@
1 +"""Public API v1 (docs/API.md). Live values come from the Redis state written by the engine; history and detail
2 +views are server-side ClickHouse aggregations (never raw points to the browser)."""
3 +
4 +from __future__ import annotations
5 +
6 +import datetime as dt
7 +from typing import Any
8 +
9 +from fastapi import APIRouter, HTTPException, Query, Response
10 +
11 +from .. import __version__
12 +from ..asn import asndb
13 +from ..config import COMPONENT_IDS
14 +from ..db import ch, pg, rds
15 +from ..engine.events import incident_dict
16 +from ..regions import COUNTRY_CENTROIDS, country_name, load_regions
17 +from ..registry import get_service, get_target, list_probes, list_services, list_targets, load_config
18 +from ..util import iso, loads, parse_ts, r1, r2, r3, utcnow
19 +
20 +router = APIRouter(prefix="/api/v1", tags=["public"])
21 +
22 +RANGES = {"1h": (3600, 10), "6h": (6 * 3600, 60), "24h": (86400, 60), "7d": (7 * 86400, 300), "30d": (30 * 86400, 3600),
23 + "1y": (365 * 86400, 86400)}
24 +
25 +
26 +def _nf(what: str = "not_found") -> HTTPException:
27 + return HTTPException(404, {"error": what})
28 +
29 +
30 +def _esc(s: str) -> str:
31 + return s.replace("\\", "\\\\").replace("'", "\\'")
32 +
33 +
34 +async def _live(key: str, default: Any = None) -> Any:
35 + return await rds.get_json(f"ip:live:{key}", default)
36 +
37 +
38 +# ── status / ticker ─────────────────────────────────────────────────────────────────────────────────────────────────
39 +
40 +@router.get("/status")
41 +async def status() -> dict[str, Any]:
42 + st = await _live("status") or {}
43 + now = utcnow()
44 + mins = [(now - dt.timedelta(minutes=i)).strftime("%Y%m%d%H%M") for i in range(5)]
45 + last_run = await rds.r().get("ip:engine:last_run")
46 + cyc = await rds.r().get("ip:engine:cycle_ms")
47 + cfg = await load_config()
48 + engine_ok = False
49 + if last_run:
50 + t = parse_ts(last_run.decode())
51 + engine_ok = bool(t and (now - t).total_seconds() < 3 * int(cfg.engine.get("cycle_seconds", 10)) + 5)
52 + internal = st.get("internal_status", "stale")
53 + if not engine_ok:
54 + internal = "stale"
55 + return {
56 + "ok": internal == "ok", "ts": iso(now), "internal_status": internal, "reasons": st.get("reasons", []),
57 + "engine": {"last_run": last_run.decode() if last_run else None, "cycle_ms": int(cyc) if cyc else None,
58 + "cycle_seconds": cfg.engine.get("cycle_seconds", 10)},
59 + "ingest": {"last_batch": st.get("last_batch"), "batches_5m": await rds.sum_minutes("batch", mins),
60 + "measurements_5m": await rds.sum_minutes("meas", mins)},
61 + "probes": st.get("probes", {"fresh": 0, "total": 0, "excluded": []}),
62 + "bgp": st.get("bgp", {"fresh": False, "last_message": None, "collectors": 0}),
63 + "stores": st.get("stores", {}), "version": __version__,
64 + }
65 +
66 +
67 +async def ticker_payload() -> dict[str, Any]:
68 + now = utcnow()
69 + m1 = [(now - dt.timedelta(minutes=i)).strftime("%Y%m%d%H%M") for i in range(1, 2)] # last full minute
70 + m1b = [(now - dt.timedelta(minutes=i)).strftime("%Y%m%d%H%M") for i in range(0, 2)]
71 + meas_min = await rds.sum_minutes("meas", m1)
72 + bgp = await rds.get_json("ip:bgp:rate") or {}
73 + g = await _live("global") or {}
74 + regions = await _live("regions") or []
75 + services = await _live("services") or []
76 + st = await _live("status") or {}
77 + lat = await _live("latency") or {}
78 + routing = await _live("routing") or {}
79 + extra = await _live("extra") or {}
80 + dnsf = extra.get("dns_failures_per_min")
81 + rchg = extra.get("route_changes_per_min")
82 + levels = [r.get("level") for r in regions if r.get("pressure") is not None]
83 + inc = await _live("incidents") or []
84 + return {
85 + "ts": iso(now),
86 + "bgp_updates_per_s": bgp.get("updates_per_s"), "bgp_withdrawals_per_s": bgp.get("withdrawals_per_s"),
87 + "bgp_updates_per_min": await rds.sum_minutes("bgp", m1),
88 + "probes_active": (st.get("probes") or {}).get("fresh"), "probes_total": (st.get("probes") or {}).get("total"),
89 + "measurements_per_s": r1(meas_min / 60.0), "measurements_per_min": meas_min,
90 + "measurements_2m": await rds.sum_minutes("meas", m1b),
91 + "targets_degraded": sum(1 for s in services if (s.get("pressure") or 0) >= 40),
92 + "targets_total": (g.get("coverage") or {}).get("targets"),
93 + "regions_elevated": sum(1 for lv in levels if lv in ("elevated", "stressed")),
94 + "regions_normal": sum(1 for lv in levels if lv in ("calm", "normal")),
95 + "regions_severe": sum(1 for lv in levels if lv in ("high", "severe", "extreme")),
96 + "dns_failures_per_min": dnsf, "median_global_rtt_ms": (lat.get("global") or {}).get("rtt_ms_median"),
97 + "route_changes_per_min": rchg, "active_incidents": len(inc),
98 + "routing_score": routing.get("score"), "internal_status": g.get("internal_status", st.get("internal_status", "stale")),
99 + }
100 +
101 +
102 +@router.get("/ticker")
103 +async def ticker() -> dict[str, Any]:
104 + return await ticker_payload()
105 +
106 +
107 +# ── pressure ────────────────────────────────────────────────────────────────────────────────────────────────────────
108 +
109 +@router.get("/pressure/global")
110 +async def pressure_global() -> dict[str, Any]:
111 + g = await _live("global")
112 + if not g:
113 + cfg = await load_config()
114 + return {"ts": iso(utcnow()), "pressure": None, "level": "unknown", "level_label": "Unknown", "stale": True,
115 + "internal_status": "stale", "components": [], "explain": [], "coverage": {}, "confidence": 0,
116 + "delta_1h": None, "delta_24h": None, "velocity_per_h": None, "acceleration_per_h2": None,
117 + "volatility_1h": None, "trend": "stable", "sparkline_1h": [None] * 60, "levels": cfg.levels}
118 + return g
119 +
120 +
121 +@router.get("/pressure/history")
122 +async def pressure_history(scope_type: str = "global", scope_id: str | None = None, range: str = "24h") -> Response:
123 + if range not in RANGES:
124 + raise HTTPException(422, {"error": "validation", "detail": f"range must be one of {list(RANGES)}"})
125 + seconds, step = RANGES[range]
126 + sid = scope_id or ("global" if scope_type == "global" else None)
127 + if scope_type == "component":
128 + sid = "global"
129 + if not sid:
130 + raise HTTPException(422, {"error": "validation", "detail": "scope_id required"})
131 + st = "global" if scope_type == "component" else scope_type
132 + rows = await ch.query(f"""
133 + SELECT toStartOfInterval(ts, INTERVAL {step} SECOND) AS b, avg(pressure) AS pressure, avg(confidence) AS confidence,
134 + {', '.join(f'avg({c}) AS {c}' for c in COMPONENT_IDS)}
135 + FROM pressure_history WHERE scope_type = '{_esc(st)}' AND scope_id = '{_esc(sid)}' AND ts >= now() - INTERVAL {seconds} SECOND
136 + GROUP BY b ORDER BY b
137 + """)
138 + points = []
139 + for r in rows:
140 + p = float(r["pressure"])
141 + if scope_type == "component" and scope_id in COMPONENT_IDS:
142 + p = float(r[scope_id]) if r.get(scope_id) is not None else None # type: ignore[assignment]
143 + points.append({"ts": r["b"], "pressure": r1(p), "confidence": r2(r.get("confidence")),
144 + "components": {c: r1(r.get(c)) for c in COMPONENT_IDS}})
145 + vals = [p["pressure"] for p in points if p["pressure"] is not None]
146 + summary = {"min": min(vals) if vals else None, "max": max(vals) if vals else None,
147 + "avg": r1(sum(vals) / len(vals)) if vals else None,
148 + "max_ts": max(points, key=lambda p: p["pressure"] or -1)["ts"] if vals else None}
149 + body = {"scope_type": scope_type, "scope_id": scope_id, "range": range, "step_seconds": step, "points": points, "summary": summary}
150 + from ..util import dumps
151 +
152 + headers = {"Cache-Control": "public, max-age=30"} if seconds >= 86400 else {"Cache-Control": "no-store"}
153 + return Response(dumps(body), media_type="application/json", headers=headers)
154 +
155 +
156 +@router.get("/pressure/regions")
157 +async def pressure_regions() -> dict[str, Any]:
158 + return {"ts": iso(utcnow()), "regions": await _live("regions", [])}
159 +
160 +
161 +@router.get("/pressure/countries")
162 +async def pressure_countries() -> dict[str, Any]:
163 + return {"ts": iso(utcnow()), "countries": await _live("countries", [])}
164 +
165 +
166 +async def _history_24h(scope_type: str, scope_id: str) -> dict[str, Any]:
167 + rows = await ch.query(f"""
168 + SELECT toStartOfMinute(ts) AS b, avg(pressure) AS pressure FROM pressure_history
169 + WHERE scope_type='{_esc(scope_type)}' AND scope_id='{_esc(scope_id)}' AND ts >= now() - INTERVAL 24 HOUR GROUP BY b ORDER BY b
170 + """)
171 + return {"step_seconds": 60, "points": [{"ts": r["b"], "pressure": r1(r["pressure"])} for r in rows]}
172 +
173 +
174 +async def _baseline_7d(scope_type: str, scope_id: str) -> dict[str, Any]:
175 + r = await ch.query_one(f"""
176 + SELECT quantileTDigest(0.5)(pressure) AS median, quantileTDigest(0.9)(pressure) AS p90, count() AS n FROM pressure_history
177 + WHERE scope_type='{_esc(scope_type)}' AND scope_id='{_esc(scope_id)}' AND ts >= now() - INTERVAL 7 DAY
178 + """)
179 + return {"median": r1(r.get("median")) if r and r.get("n") else None, "p90": r1(r.get("p90")) if r and r.get("n") else None}
180 +
181 +
182 +async def _incidents_for(scope_type: str | None = None, scope_id: str | None = None, *, limit: int = 20) -> list[dict[str, Any]]:
183 + if scope_type:
184 + rows = await pg.fetch("SELECT * FROM events WHERE scope_type=$1 AND scope_id=$2 ORDER BY started_at DESC LIMIT $3",
185 + scope_type, scope_id, limit)
186 + else:
187 + rows = await pg.fetch("SELECT * FROM events ORDER BY started_at DESC LIMIT $1", limit)
188 + return [incident_dict(dict(r)) for r in rows]
189 +
190 +
191 +async def _matrix_rows(where_probe_region: str | None = None) -> list[dict[str, Any]]:
192 + lat = await _live("latency") or {}
193 + rows = lat.get("matrix") or []
194 + if where_probe_region:
195 + rows = [r for r in rows if r["from"] == where_probe_region or r["to"] == where_probe_region]
196 + return rows
197 +
198 +
199 +@router.get("/pressure/region/{region_id}")
200 +async def pressure_region(region_id: str) -> dict[str, Any]:
201 + regions = load_regions()
202 + reg = regions.get(region_id)
203 + if not reg:
204 + raise _nf()
205 + live = next((r for r in (await _live("regions") or []) if r["id"] == region_id), None)
206 + base = live or {"id": reg.id, "name": reg.name, "continent": reg.continent, "lat": reg.lat, "lon": reg.lon,
207 + "pressure": None, "level": "unknown", "level_label": "Unknown", "components": {}, "probes": 0, "targets": 0}
208 + probes = [p for p in await list_probes() if p["region"] == region_id]
209 + targets = [t for t in await list_targets(enabled_only=True) if t["region"] == region_id]
210 + asns = [a for a in (await _live("asns") or []) if region_id in (a.get("regions_observed") or [])][:15]
211 + services = sorted({t["service_id"] for t in targets if t.get("service_id")})
212 + svc_live = {s["slug"]: s for s in (await _live("services") or [])}
213 + return {
214 + **base,
215 + "history_24h": await _history_24h("region", region_id), "baseline_7d": await _baseline_7d("region", region_id),
216 + "incidents": await _incidents_for("region", region_id), "top_asns": asns,
217 + "top_services": [{"slug": s, "name": svc_live.get(s, {}).get("name", s), "pressure": svc_live.get(s, {}).get("pressure"),
218 + "observed_availability_24h": None} for s in services][:20],
219 + "probes": await _probes_public(probes), "matrix": await _matrix_rows(region_id),
220 + "targets": await _targets_summary(targets),
221 + }
222 +
223 +
224 +@router.get("/pressure/country/{cc}")
225 +async def pressure_country(cc: str) -> dict[str, Any]:
226 + cc = cc.upper()
227 + live = next((c for c in (await _live("countries") or []) if c["cc"] == cc), None)
228 + probes = [p for p in await list_probes() if (p.get("country") or "").upper() == cc]
229 + targets = [t for t in await list_targets(enabled_only=True) if (t.get("country") or "").upper() == cc]
230 + if not live and not probes and not targets:
231 + raise _nf()
232 + regions = load_regions()
233 + lat, lon = COUNTRY_CENTROIDS.get(cc, (0.0, 0.0))
234 + base = live or {"cc": cc, "name": country_name(cc), "region": regions.region_of_country(cc), "lat": lat, "lon": lon,
235 + "pressure": None, "level": "unknown", "level_label": "Unknown", "components": {}, "probes": len(probes),
236 + "targets": len(targets), "role": "both" if probes and targets else ("probe" if probes else "target"),
237 + "coverage_ok": False, "delta_1h": None, "trend": "stable", "confidence": 0}
238 + svc_live = {s["slug"]: s for s in (await _live("services") or [])}
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}
241 + return {
242 + **base, "history_24h": await _history_24h("country", cc), "baseline_7d": await _baseline_7d("country", cc),
243 + "incidents": await _incidents_for("country", cc), "asns": sorted(asn_ids, key=lambda a: -(a.get("pressure") or 0))[:15],
244 + "services": [{"slug": s, "name": svc_live.get(s, {}).get("name", s), "pressure": svc_live.get(s, {}).get("pressure")} for s in services],
245 + "probes": await _probes_public(probes), "targets": await _targets_summary(targets),
246 + }
247 +
248 +
249 +# ── asns ────────────────────────────────────────────────────────────────────────────────────────────────────────────
250 +
251 +@router.get("/asns")
252 +async def asns() -> dict[str, Any]:
253 + live = await _live("asns") or []
254 + out = []
255 + for a in live:
256 + out.append({"asn": a["asn"], "name": a.get("name") or f"AS{a['asn']}", "country": a.get("country"), "pressure": a.get("pressure"),
257 + "level": a.get("level"), "routing": a.get("routing"), "latency": a.get("latency"), "availability": a.get("availability"),
258 + "targets": a.get("targets"), "prefixes_observed": None, "importance": a.get("importance")})
259 + return {"ts": iso(utcnow()), "asns": out}
260 +
261 +
262 +@router.get("/pressure/asn/{asn}")
263 +async def pressure_asn(asn: int) -> dict[str, Any]:
264 + live = next((a for a in (await _live("asns") or []) if int(a["asn"]) == asn), None)
265 + name = asndb.name(asn) or (live or {}).get("name") or f"AS{asn}"
266 + bgp = await ch.query(f"""
267 + SELECT toStartOfHour(ts) AS b, sum(announcements) AS announcements, sum(withdrawals) AS withdrawals, max(prefixes) AS prefixes
268 + FROM bgp_origin_1m WHERE origin_asn = {int(asn)} AND ts >= now() - INTERVAL 24 HOUR GROUP BY b ORDER BY b
269 + """)
270 + h1 = await ch.query_one(f"SELECT sum(announcements) a, sum(withdrawals) w, max(prefixes) p FROM bgp_origin_1m WHERE origin_asn={int(asn)} AND ts >= now() - INTERVAL 1 HOUR")
271 + d1 = await ch.query_one(f"SELECT max(prefixes) p FROM bgp_origin_1m WHERE origin_asn={int(asn)} AND ts >= now() - INTERVAL 24 HOUR")
272 + if not live and not bgp and not h1.get("a"): # type: ignore[union-attr]
273 + raise _nf()
274 + targets = []
275 + if live:
276 + ids = set()
277 + for t in await list_targets(enabled_only=True):
278 + # ASN attribution comes from resolved IPs in the last window
279 + pass
280 + tl = await _live("targets_by_asn") or {}
281 + ids = set(tl.get(str(asn), []))
282 + targets = await _targets_summary([t for t in await list_targets(enabled_only=True) if t["target_id"] in ids])
283 + cfg = await load_config()
284 + pr = (live or {}).get("pressure")
285 + lv, ll = cfg.level_for(pr)
286 + prev = await _pressure_ago("asn", str(asn), 60)
287 + return {
288 + "asn": asn, "name": name, "country": asndb.country(asn) or (live or {}).get("country"), "importance": (live or {}).get("importance", 2),
289 + "ts": iso(utcnow()), "pressure": pr, "level": lv, "level_label": ll,
290 + "delta_1h": r1(pr - prev) if (pr is not None and prev is not None) else None,
291 + "trend": "stable" if pr is None or prev is None or abs(pr - prev) < 1 else ("rising" if pr > prev else "falling"),
292 + "confidence": (live or {}).get("confidence", 0), "components": (live or {}).get("components", {}),
293 + "bgp": {"prefixes_observed_24h": (d1 or {}).get("p"), "announcements_1h": (h1 or {}).get("a"), "withdrawals_1h": (h1 or {}).get("w"),
294 + "churn_ratio": None, "origin_changes_1h": None, "path_stability": None,
295 + "series_24h": [{"ts": r["b"], "announcements": r["announcements"], "withdrawals": r["withdrawals"]} for r in bgp],
296 + **((live or {}).get("bgp") or {})},
297 + "regions_observed": (live or {}).get("regions_observed", []), "targets": targets,
298 + "history_24h": await _history_24h("asn", str(asn)), "incidents": await _incidents_for("asn", str(asn)),
299 + }
300 +
301 +
302 +async def _pressure_ago(scope_type: str, scope_id: str, minutes: int) -> float | None:
303 + r = await ch.query_one(f"""SELECT argMax(pressure, ts) AS p FROM pressure_history WHERE scope_type='{_esc(scope_type)}' AND scope_id='{_esc(scope_id)}'
304 + AND ts BETWEEN now() - INTERVAL {minutes + 2} MINUTE AND now() - INTERVAL {max(0, minutes - 2)} MINUTE""")
305 + return float(r["p"]) if r and r.get("p") is not None else None
306 +
307 +
308 +# ── services ────────────────────────────────────────────────────────────────────────────────────────────────────────
309 +
310 +async def _observed(target_ids: list[str], hours: int) -> dict[str, Any]:
311 + if not target_ids:
312 + return {}
313 + ids = ",".join(f"'{_esc(t)}'" for t in target_ids)
314 + r = await ch.query_one(f"""
315 + SELECT avg(ok) AS availability, quantileTDigest(0.5)(ttfb_ms) AS ttfb, quantileTDigest(0.5)(tls_ms) AS tls,
316 + countIf(ok = 0) AS failures, count() AS n
317 + FROM measurements WHERE kind = 'http' AND target_id IN ({ids}) AND ts >= now() - INTERVAL {hours} HOUR
318 + """)
319 + return r or {}
320 +
321 +
322 +@router.get("/services")
323 +async def services() -> dict[str, Any]:
324 + live = await _live("services") or []
325 + targets = await list_targets(enabled_only=True)
326 + out = []
327 + for s in live:
328 + tids = [t["target_id"] for t in targets if t.get("service_id") == s["slug"]]
329 + obs = await _observed(tids, 24) if tids else {}
330 + out.append({**{k: s.get(k) for k in ("slug", "name", "category", "pressure", "level", "level_label", "targets", "affected_regions", "importance")},
331 + "observed_availability_24h": r3(obs.get("availability")) if obs.get("n") else None,
332 + "vendor_status": s.get("vendor_status")})
333 + return {"ts": iso(utcnow()), "services": out}
334 +
335 +
336 +@router.get("/service/{slug}")
337 +async def service(slug: str) -> dict[str, Any]:
338 + svc = await get_service(slug)
339 + if not svc:
340 + raise _nf()
341 + live = next((s for s in (await _live("services") or []) if s["slug"] == slug), None) or {}
342 + targets = [t for t in await list_targets(enabled_only=True) if t.get("service_id") == slug]
343 + tids = [t["target_id"] for t in targets]
344 + o24, o1 = await _observed(tids, 24), await _observed(tids, 1)
345 + base = await ch.query_one(f"""SELECT quantileTDigest(0.5)(ttfb_ms) AS ttfb FROM measurements WHERE kind='http' AND target_id IN ({','.join(f"'{_esc(t)}'" for t in tids) or "''"})
346 + AND ts >= now() - INTERVAL 7 DAY AND ts < now() - INTERVAL 10 MINUTE""") if tids else {}
347 + probes = {p["probe_id"]: p for p in await list_probes()}
348 + matrix = []
349 + latest_by_target: dict[str, dict[str, Any]] = {}
350 + for tid in tids:
351 + h = await rds.r().hgetall(f"ip:latest:{tid}")
352 + latest_by_target[tid] = {k.decode(): loads(v) for k, v in h.items()}
353 + for pid, p in sorted(probes.items()):
354 + row = {"probe_id": pid, "probe_region": p["region"], "targets": []}
355 + for tid in tids:
356 + m = latest_by_target.get(tid, {}).get(f"{pid}|http|")
357 + if m:
358 + row["targets"].append({"target_id": tid, "ok": bool(m["ok"]), "ttfb_ms": r1(m.get("ttfb_ms")), "z": None, "ts": m["ts"],
359 + "error": m.get("error") or None, "http_status": m.get("http_status")})
360 + if row["targets"]:
361 + matrix.append(row)
362 + vs = live.get("vendor_status")
363 + affected = live.get("affected_regions") or []
364 + discrepancy = None
365 + if vs and vs.get("indicator") in ("none",) and affected and (live.get("pressure") or 0) >= 30:
366 + discrepancy = f"Vendor reports no incident; we observe elevated pressure from {len(affected)} probe region(s)."
367 + elif vs and vs.get("indicator") not in ("none", "unknown", None) and not affected and (live.get("pressure") or 0) < 20:
368 + discrepancy = "Vendor declares an incident; our probes do not currently observe degradation."
369 + regions = load_regions()
370 + return {
371 + **{k: live.get(k) for k in ("pressure", "level", "level_label", "confidence", "components", "importance")},
372 + "slug": slug, "name": svc["name"], "category": svc.get("category"), "asns": svc.get("asns") or [],
373 + "targets_count": len(targets),
374 + "observed": {"availability_24h": r3(o24.get("availability")) if o24.get("n") else None,
375 + "availability_1h": r3(o1.get("availability")) if o1.get("n") else None,
376 + "ttfb_ms_median_1h": r1(o1.get("ttfb")) if o1.get("n") else None,
377 + "ttfb_ms_baseline": r1((base or {}).get("ttfb")), "tls_ms_median_1h": r1(o1.get("tls")) if o1.get("n") else None,
378 + "failures_1h": o1.get("failures")},
379 + "affected_regions": [{"id": r, "name": regions.get(r).name if regions.get(r) else r,
380 + "observation": "Elevated pressure observed from probes in this region"} for r in affected],
381 + "vendor_status": vs, "discrepancy": discrepancy, "matrix": matrix,
382 + "targets": await _targets_summary(targets), "history_24h": await _history_24h("service", slug),
383 + "incidents": await _incidents_for("service", slug),
384 + }
385 +
386 +
387 +# ── targets & probes ────────────────────────────────────────────────────────────────────────────────────────────────
388 +
389 +async def _targets_summary(targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
390 + if not targets:
391 + return []
392 + ids = ",".join(f"'{_esc(t['target_id'])}'" for t in targets)
393 + rows = await ch.query(f"""
394 + SELECT target_id, avg(ok) AS ok_ratio, quantileTDigest(0.5)(ttfb_ms) AS ttfb FROM measurements
395 + WHERE kind='http' AND target_id IN ({ids}) AND ts >= now() - INTERVAL 1 HOUR GROUP BY target_id
396 + """)
397 + stats = {r["target_id"]: r for r in rows}
398 + out = []
399 + for t in targets:
400 + s = stats.get(t["target_id"], {})
401 + out.append({"target_id": t["target_id"], "name": t["name"], "hostname": t["hostname"], "category": t["category"],
402 + "provider": t.get("provider"), "service_id": t.get("service_id"), "country": t.get("country"), "region": t["region"],
403 + "importance": t["importance"], "tier": t["tier"], "pressure": None,
404 + "ok_ratio_1h": r3(s.get("ok_ratio")), "ttfb_ms_median_1h": r1(s.get("ttfb"))})
405 + return out
406 +
407 +
408 +@router.get("/targets")
409 +async def targets(category: str | None = None) -> dict[str, Any]:
410 + ts = await list_targets(enabled_only=True)
411 + if category:
412 + ts = [t for t in ts if t["category"] == category]
413 + return {"targets": await _targets_summary(ts)}
414 +
415 +
416 +@router.get("/target/{target_id}")
417 +async def target(target_id: str) -> dict[str, Any]:
418 + t = await get_target(target_id)
419 + if not t:
420 + raise _nf()
421 + h = await rds.r().hgetall(f"ip:latest:{target_id}")
422 + latest = []
423 + for k, v in h.items():
424 + pid, kind, resolver = k.decode().split("|")
425 + m = loads(v)
426 + latest.append({"probe_id": pid, "kind": kind, "resolver": resolver or None, "ts": m["ts"], "ok": bool(m["ok"]), "error": m.get("error") or None,
427 + "dns_ms": r1(m.get("dns_ms")), "tcp_ms": r1(m.get("tcp_ms")), "tls_ms": r1(m.get("tls_ms")), "ttfb_ms": r1(m.get("ttfb_ms")),
428 + "http_status": m.get("http_status"), "resolved_ip": m.get("resolved_ip") or None, "packet_loss": m.get("packet_loss"),
429 + "rtt_avg_ms": r1(m.get("rtt_avg_ms")), "dns_rcode": m.get("dns_rcode") or None, "dns_answers": m.get("dns_answers"), "z": None})
430 + latest.sort(key=lambda x: (x["probe_id"], x["kind"], x["resolver"] or ""))
431 + series = await ch.query(f"""
432 + SELECT toStartOfFifteenMinutes(ts) AS b, quantileTDigest(0.5)(ttfb_ms) AS ttfb, avg(ok) AS ok_ratio FROM measurements
433 + WHERE kind='http' AND target_id='{_esc(target_id)}' AND ts >= now() - INTERVAL 24 HOUR GROUP BY b ORDER BY b
434 + """)
435 + dns_rows = [m for m in latest if m["kind"] == "dns"]
436 + oks = {m["resolver"]: m["ok"] for m in dns_rows}
437 + return {
438 + **(await _targets_summary([t]))[0], "url": t.get("url"), "ip": t.get("ip"), "checks": t.get("checks"), "traceroute": t.get("traceroute"),
439 + "latest_by_probe": latest,
440 + "series_24h": {"step_seconds": 900, "points": [{"ts": r["b"], "ttfb_ms_p50": r1(r["ttfb"]), "ok_ratio": r3(r["ok_ratio"])} for r in series]},
441 + "dns": {"resolvers": [{"resolver": m["resolver"], "rcode": m["dns_rcode"], "answers": m["dns_answers"], "ms": m["dns_ms"], "probe_id": m["probe_id"]} for m in dns_rows],
442 + "disagreement": bool(oks) and any(oks.values()) and not all(oks.values())},
443 + }
444 +
445 +
446 +async def _probes_public(probes: list[dict[str, Any]]) -> list[dict[str, Any]]:
447 + now = utcnow()
448 + cfg = await load_config()
449 + fresh_s = int(cfg.engine.get("probe_fresh_seconds", 180))
450 + st = await _live("status") or {}
451 + excluded = set((st.get("probes") or {}).get("excluded") or [])
452 + out = []
453 + ids = [p["probe_id"] for p in probes]
454 + seen = await rds.r().mget([f"ip:probe:{p}:seen" for p in ids]) if ids else []
455 + healths = await rds.r().mget([f"ip:probe:{p}:health" for p in ids]) if ids else []
456 + counts = {}
457 + if ids:
458 + rows = await ch.query(f"""SELECT probe_id, count() AS n FROM measurements WHERE ts >= now() - INTERVAL 1 HOUR
459 + AND probe_id IN ({','.join(f"'{_esc(i)}'" for i in ids)}) GROUP BY probe_id""")
460 + counts = {r["probe_id"]: int(r["n"]) for r in rows}
461 + up = await ch.query(f"""SELECT probe_id, uniqExact(toStartOfFiveMinutes(ts)) AS buckets FROM measurements WHERE ts >= now() - INTERVAL 24 HOUR
462 + AND probe_id IN ({','.join(f"'{_esc(i)}'" for i in ids)}) GROUP BY probe_id""")
463 + uptime = {r["probe_id"]: min(1.0, int(r["buckets"]) / 288.0) for r in up}
464 + else:
465 + uptime = {}
466 + for p, s, hraw in zip(probes, seen, healths, strict=False):
467 + last = parse_ts(s.decode()) if s else (parse_ts(p.get("last_seen")) if p.get("last_seen") else None)
468 + age = (now - last).total_seconds() if last else None
469 + status = "offline" if age is None or age > 3600 else "stale" if age > fresh_s else "online"
470 + if p["probe_id"] in excluded:
471 + status = "excluded"
472 + if not p.get("enabled", True):
473 + status = "disabled"
474 + h = loads(hraw) if hraw else {}
475 + out.append({"probe_id": p["probe_id"], "name": p["name"], "region": p["region"], "country": p.get("country"), "city": p.get("city"),
476 + "provider": p.get("provider"), "asn": p.get("asn"), "lat": p.get("lat"), "lon": p.get("lon"), "status": status,
477 + "last_seen": iso(last) if last else None, "version": p.get("version") or h.get("agent_version"),
478 + "measurements_1h": counts.get(p["probe_id"], 0), "uptime_24h": r3(uptime.get(p["probe_id"], 0.0)),
479 + "clock_offset_ms": h.get("clock_offset_ms"), "capabilities": p.get("capabilities") or h.get("capabilities") or [],
480 + "node": p.get("node")})
481 + return out
482 +
483 +
484 +@router.get("/probes")
485 +async def probes() -> dict[str, Any]:
486 + return {"probes": await _probes_public(await list_probes())}
487 +
488 +
489 +# ── incidents ───────────────────────────────────────────────────────────────────────────────────────────────────────
490 +
491 +@router.get("/incidents")
492 +async def incidents(status: str = "all", limit: int = Query(50, le=200), offset: int = 0) -> dict[str, Any]:
493 + if status == "active":
494 + where = "WHERE status <> 'resolved'"
495 + elif status == "resolved":
496 + where = "WHERE status = 'resolved'"
497 + else:
498 + where = ""
499 + total = await pg.fetchval(f"SELECT count(*) FROM events {where}")
500 + rows = await pg.fetch(f"SELECT * FROM events {where} ORDER BY (status <> 'resolved') DESC, started_at DESC LIMIT $1 OFFSET $2", limit, offset)
501 + return {"total": total, "incidents": [incident_dict(dict(r)) for r in rows]}
502 +
503 +
504 +@router.get("/incident/{slug}")
505 +async def incident(slug: str) -> dict[str, Any]:
506 + r = await pg.fetchrow("SELECT * FROM events WHERE slug=$1 OR event_id=$1", slug)
507 + if not r:
508 + raise _nf()
509 + ev = dict(r)
510 + tl = await pg.fetch("SELECT ts, status, pressure, note FROM event_timeline WHERE event_id=$1 ORDER BY ts", ev["event_id"])
511 + start = ev["started_at"] - dt.timedelta(minutes=30)
512 + end = ev["ended_at"] or utcnow()
513 + span = (end - start).total_seconds()
514 + step = 10 if span <= 3600 else 60 if span <= 6 * 3600 else 300
515 + series = await ch.query(f"""
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
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 + AND (scope_type='global' OR (scope_type='{_esc(ev['scope_type'])}' AND scope_id='{_esc(ev['scope_id'] or '')}'))
521 + GROUP BY b ORDER BY b
522 + """)
523 + ann = await pg.fetch("SELECT ts, author, text FROM annotations WHERE (scope_type=$1 AND scope_id=$2) OR (scope_type='event' AND scope_id=$3) ORDER BY ts",
524 + ev["scope_type"], ev["scope_id"], ev["event_id"])
525 + return {
526 + **incident_dict(ev),
527 + "timeline": [{"ts": iso(t["ts"]), "status": t["status"], "pressure": r1(t["pressure"]), "note": t["note"]} for t in tl],
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]},
530 + "probes": [{**p, "observation": None} for p in (ev.get("probes") or [])],
531 + "targets": [{**t, "observation": None} for t in (ev.get("targets") or [])],
532 + "bgp": ev.get("bgp"),
533 + "annotations": [{"ts": iso(a["ts"]), "author": a["author"], "text": a["text"]} for a in ann],
534 + "review_note": ev.get("review_note"),
535 + }
536 +
537 +
538 +# ── fronts, bgp, latency ────────────────────────────────────────────────────────────────────────────────────────────
539 +
540 +@router.get("/fronts")
541 +async def fronts() -> dict[str, Any]:
542 + return {"ts": iso(utcnow()), "fronts": await _live("fronts", [])}
543 +
544 +
545 +@router.get("/bgp/stats")
546 +async def bgp_stats() -> dict[str, Any]:
547 + live = await _live("routing") or {}
548 + from ..bgp.rislive import COLLECTOR_LOCATIONS
549 +
550 + series = await ch.query("""
551 + SELECT toStartOfMinute(ts) AS b, sum(announcements) AS announcements, sum(withdrawals) AS withdrawals
552 + FROM bgp_stats_10s WHERE ts >= now() - INTERVAL 1 HOUR GROUP BY b ORDER BY b
553 + """)
554 + m1 = await ch.query_one("""SELECT sum(unique_prefixes) AS up, sum(unique_origins) AS uo, sum(origin_changes) AS oc, max(peers) AS peers
555 + FROM bgp_stats_10s WHERE ts >= now() - INTERVAL 1 MINUTE""") or {}
556 + top = await ch.query("""
557 + SELECT origin_asn, sum(announcements) AS announcements, sum(withdrawals) AS withdrawals FROM bgp_origin_1m
558 + WHERE ts >= now() - INTERVAL 1 HOUR AND origin_asn > 0 GROUP BY origin_asn ORDER BY announcements + withdrawals DESC LIMIT 15
559 + """)
560 + peers_total = await ch.query_one("SELECT sum(p) AS peers FROM (SELECT collector, max(peers) AS p FROM bgp_stats_10s WHERE ts >= now() - INTERVAL 1 MINUTE GROUP BY collector)") or {}
561 + cols = []
562 + for c in live.get("collectors") or []:
563 + cols.append({**c, "location": COLLECTOR_LOCATIONS.get(c["id"], c["id"])})
564 + return {
565 + "ts": live.get("ts") or iso(utcnow()), "fresh": live.get("fresh", False),
566 + "updates_per_s": live.get("updates_per_s"), "announcements_per_s": live.get("announcements_per_s"),
567 + "withdrawals_per_s": live.get("withdrawals_per_s"), "baseline": live.get("baseline") or {}, "ratio": live.get("ratio") or {},
568 + "score": live.get("score"),
569 + "unique_prefixes_1m": m1.get("up"), "unique_origins_1m": m1.get("uo"), "origin_changes_1m": m1.get("oc"), "peers": peers_total.get("peers"),
570 + "collectors": cols,
571 + "series_1h": [{"ts": s["b"], "announcements": s["announcements"], "withdrawals": s["withdrawals"]} for s in series],
572 + "top_origins_1h": [{"asn": t["origin_asn"], "name": asndb.name(t["origin_asn"]), "announcements": t["announcements"], "withdrawals": t["withdrawals"]} for t in top],
573 + }
574 +
575 +
576 +@router.get("/latency")
577 +async def latency() -> dict[str, Any]:
578 + live = await _live("latency")
579 + if live:
580 + return live
581 + return {"ts": iso(utcnow()), "global": {}, "matrix": [], "by_probe": []}
582 +
583 +
584 +# ── routes ──────────────────────────────────────────────────────────────────────────────────────────────────────────
585 +
586 +def _hops(ips: list[str], rtts: list[float | None] | None) -> list[dict[str, Any]]:
587 + out = []
588 + for i, ip in enumerate(ips):
589 + asn, name = asndb.lookup(ip)
590 + out.append({"n": i + 1, "ip": ip, "asn": asn, "asn_name": name, "rtt_ms": r1(rtts[i]) if rtts and i < len(rtts) and rtts[i] is not None else None,
591 + "private": asndb.is_private(ip)})
592 + return out
593 +
594 +
595 +@router.get("/routes/pairs")
596 +async def route_pairs() -> dict[str, Any]:
597 + rows = await ch.query("""
598 + SELECT probe_id, target_id, uniqExact(route_hash) AS routes_24h, argMax(route_hash, ts) AS current, count() AS n,
599 + countIf(changed) AS changes
600 + FROM (SELECT probe_id, target_id, ts, route_hash,
601 + route_hash != lagInFrame(route_hash, 1, route_hash) OVER (PARTITION BY probe_id, target_id ORDER BY ts) AS changed
602 + FROM traceroutes WHERE ts >= now() - INTERVAL 24 HOUR)
603 + GROUP BY probe_id, target_id ORDER BY changes DESC, probe_id, target_id
604 + """)
605 + return {"pairs": [{"probe_id": r["probe_id"], "target_id": r["target_id"], "changed_24h": int(r["changes"]), "routes_24h": int(r["routes_24h"]),
606 + "current_route_hash": r["current"], "stable": int(r["changes"]) == 0} for r in rows]}
607 +
608 +
609 +@router.get("/routes")
610 +async def routes(probe: str, target: str) -> dict[str, Any]:
611 + p = next((x for x in await list_probes() if x["probe_id"] == probe), None)
612 + t = await get_target(target)
613 + if not p or not t:
614 + raise _nf()
615 + cur = await ch.query_one(f"""SELECT * FROM traceroutes WHERE probe_id='{_esc(probe)}' AND target_id='{_esc(target)}' ORDER BY ts DESC LIMIT 1""")
616 + if not cur:
617 + raise _nf("no_traceroute")
618 + share = await ch.query(f"""
619 + SELECT route_hash, count() AS n, any(hop_ips) AS hop_ips, any(hop_rtts) AS hop_rtts, any(asn_path) AS asn_path, min(ts) AS first_seen, max(ts) AS last_seen
620 + FROM traceroutes WHERE probe_id='{_esc(probe)}' AND target_id='{_esc(target)}' AND ts >= now() - INTERVAL 7 DAY
621 + GROUP BY route_hash ORDER BY n DESC
622 + """)
623 + total = sum(int(s["n"]) for s in share) or 1
624 + dom = share[0] if share else None
625 + hist = await ch.query(f"""SELECT ts, route_hash, hop_count, total_ms FROM traceroutes WHERE probe_id='{_esc(probe)}' AND target_id='{_esc(target)}'
626 + AND ts >= now() - INTERVAL 24 HOUR ORDER BY ts""")
627 + cur_hops = _hops(cur["hop_ips"], cur.get("hop_rtts"))
628 + base_hops = _hops(dom["hop_ips"], dom.get("hop_rtts")) if dom else []
629 + cur_ips = set(cur["hop_ips"]) - {"*"}
630 + base_ips = set(dom["hop_ips"]) - {"*"} if dom else set()
631 + base_ms = await ch.query_one(f"""SELECT quantileTDigest(0.5)(total_ms) AS ms FROM traceroutes WHERE probe_id='{_esc(probe)}' AND target_id='{_esc(target)}'
632 + AND route_hash='{_esc(dom["route_hash"])}' AND ts >= now() - INTERVAL 7 DAY""") if dom else {}
633 + changed = bool(dom) and cur["route_hash"] != dom["route_hash"]
634 + dest_asn, _ = asndb.lookup(cur.get("dest_ip"))
635 + return {
636 + "probe": {"probe_id": probe, "name": p["name"], "asn": p.get("asn"), "region": p["region"]},
637 + "target": {"target_id": target, "name": t["name"], "hostname": t["hostname"], "asn": dest_asn},
638 + "current": {"ts": cur["ts"], "route_hash": cur["route_hash"], "reached": bool(cur["reached"]), "total_ms": r1(cur.get("total_ms")), "hops": cur_hops},
639 + "baseline": ({"route_hash": dom["route_hash"], "share_7d": r3(int(dom["n"]) / total), "first_seen": dom["first_seen"], "last_seen": dom["last_seen"],
640 + "hops": base_hops} if dom else None),
641 + "diff": {"changed": changed,
642 + "added": [h for h in cur_hops if h["ip"] not in base_ips and h["ip"] != "*"] if changed else [],
643 + "removed": [h for h in base_hops if h["ip"] not in cur_ips and h["ip"] != "*"] if changed else [],
644 + "asn_path_current": list(cur.get("asn_path") or []), "asn_path_baseline": list(dom.get("asn_path") or []) if dom else [],
645 + "latency_shift_ms": r1(float(cur["total_ms"]) - float(base_ms["ms"])) if (cur.get("total_ms") is not None and base_ms and base_ms.get("ms") is not None) else None,
646 + "hop_delta": int(cur["hop_count"]) - len(dom["hop_ips"]) if dom else 0},
647 + "history_24h": [{"ts": h["ts"], "route_hash": h["route_hash"], "hop_count": h["hop_count"], "total_ms": r1(h.get("total_ms"))} for h in hist],
648 + "route_share_7d": [{"route_hash": s["route_hash"], "share": r3(int(s["n"]) / total), "asn_path": list(s.get("asn_path") or [])} for s in share[:8]],
649 + }
650 +
651 +
652 +# ── history, explain, methodology, search ───────────────────────────────────────────────────────────────────────────
653 +
654 +@router.get("/history/summary")
655 +async def history_summary(year: int | None = None, month: int | None = None) -> dict[str, Any]:
656 + out: dict[str, Any] = {"year": year, "month": month}
657 + if year and month:
658 + start = dt.datetime(year, month, 1, tzinfo=dt.UTC)
659 + end = dt.datetime(year + (month == 12), (month % 12) + 1, 1, tzinfo=dt.UTC)
660 + rows = await ch.query(f"""SELECT toDate(ts) AS d, min(pressure) AS mn, max(pressure) AS mx, avg(pressure) AS av FROM pressure_history
661 + WHERE scope_type='global' AND ts >= '{start:%Y-%m-%d}' AND ts < '{end:%Y-%m-%d}' GROUP BY d ORDER BY d""")
662 + ev_days = await pg.fetch("SELECT date_trunc('day', started_at) AS d, count(*) AS n FROM events WHERE started_at >= $1 AND started_at < $2 GROUP BY d", start, end)
663 + evd = {r["d"].strftime("%Y-%m-%d"): r["n"] for r in ev_days}
664 + out["days"] = [{"date": r["d"], "min": r1(r["mn"]), "max": r1(r["mx"]), "avg": r1(r["av"]), "events": evd.get(r["d"], 0)} for r in rows]
665 + ev_where, ev_args = "WHERE started_at >= $1 AND started_at < $2", [start, end]
666 + elif year:
667 + start = dt.datetime(year, 1, 1, tzinfo=dt.UTC)
668 + end = dt.datetime(year + 1, 1, 1, tzinfo=dt.UTC)
669 + rows = await ch.query(f"""SELECT toStartOfMonth(ts) AS d, min(pressure) AS mn, max(pressure) AS mx, avg(pressure) AS av FROM pressure_history
670 + WHERE scope_type='global' AND ts >= '{start:%Y-%m-%d}' AND ts < '{end:%Y-%m-%d}' GROUP BY d ORDER BY d""")
671 + ev_m = await pg.fetch("SELECT date_trunc('month', started_at) AS d, count(*) AS n FROM events WHERE started_at >= $1 AND started_at < $2 GROUP BY d", start, end)
672 + evd = {r["d"].strftime("%Y-%m"): r["n"] for r in ev_m}
673 + out["months"] = [{"month": r["d"][:7], "min": r1(r["mn"]), "max": r1(r["mx"]), "avg": r1(r["av"]), "events": evd.get(r["d"][:7], 0)} for r in rows]
674 + ev_where, ev_args = "WHERE started_at >= $1 AND started_at < $2", [start, end]
675 + else:
676 + rows = await ch.query("""SELECT toYear(ts) AS y, min(pressure) AS mn, max(pressure) AS mx, avg(pressure) AS av FROM pressure_history
677 + WHERE scope_type='global' GROUP BY y ORDER BY y""")
678 + out["years"] = [{"year": int(r["y"]), "min": r1(r["mn"]), "max": r1(r["mx"]), "avg": r1(r["av"])} for r in rows]
679 + ev_where, ev_args = "", []
680 + evs = await pg.fetch(f"SELECT * FROM events {ev_where} ORDER BY peak_pressure DESC LIMIT 10", *ev_args)
681 + top_events = [incident_dict(dict(r)) for r in evs]
682 + asn_rows = await pg.fetch(f"""SELECT a AS asn, count(*) AS events, max(peak_pressure) AS max_pressure FROM events, unnest(affected_asns) AS a {ev_where}
683 + GROUP BY a ORDER BY events DESC, max_pressure DESC LIMIT 10""", *ev_args)
684 + reg_rows = await pg.fetch(f"""SELECT scope_id AS id, scope_label AS name, count(*) AS events, max(peak_pressure) AS max_pressure,
685 + sum(EXTRACT(EPOCH FROM (COALESCE(ended_at, now()) - started_at)))/3600 AS hours FROM events {ev_where + (' AND ' if ev_where else 'WHERE ')} scope_type='region'
686 + GROUP BY scope_id, scope_label ORDER BY events DESC LIMIT 10""", *ev_args)
687 +
688 + def largest(t: str) -> dict[str, Any] | None:
689 + c = [e for e in top_events if e["type"] == t]
690 + return c[0] if c else None
691 +
692 + months = await ch.query("SELECT DISTINCT toStartOfMonth(ts) AS m FROM pressure_history WHERE scope_type='global' ORDER BY m")
693 + out.update({
694 + "top_events": top_events,
695 + "top_asns": [{"asn": r["asn"], "name": asndb.name(r["asn"]), "events": r["events"], "max_pressure": r1(r["max_pressure"])} for r in asn_rows],
696 + "top_regions": [{"id": r["id"], "name": r["name"], "events": r["events"], "max_pressure": r1(r["max_pressure"]), "hours_elevated": r1(r["hours"])} for r in reg_rows],
697 + "largest": {"pressure": top_events[0] if top_events else None, "routing": largest("routing_instability"),
698 + "dns": largest("dns_disruption"), "latency": largest("regional_latency")},
699 + "available_months": [m["m"][:7] for m in months],
700 + })
701 + return out
702 +
703 +
704 +@router.get("/explain")
705 +async def explain() -> dict[str, Any]:
706 + e = await _live("explain")
707 + if not e:
708 + return {"ts": iso(utcnow()), "pressure": None, "components": [], "excluded_probes": [], "notes": ["engine has not published yet"]}
709 + cfg = await load_config()
710 + for comp in e.get("components", []):
711 + comp["weight"] = cfg.weights.get(comp["id"])
712 + for s in comp.get("signals", []):
713 + s["label"] = cfg.signal_label(comp["id"], s["signal_id"])
714 + return e
715 +
716 +
717 +@router.get("/methodology")
718 +async def methodology() -> dict[str, Any]:
719 + cfg = await load_config()
720 + return {"weights": cfg.weights, "levels": cfg.levels, "engine": cfg.engine, "components": cfg.components,
721 + "importance_weights": {str(k): v for k, v in cfg.importance_weights.items()}, "events": cfg.events, "fronts": cfg.fronts,
722 + "version": cfg.version, "updated_at": cfg.updated_at, "source": cfg.source}
723 +
724 +
725 +@router.get("/search")
726 +async def search(q: str = Query(min_length=1, max_length=80)) -> dict[str, Any]:
727 + ql = q.lower().strip()
728 + res: list[dict[str, Any]] = []
729 + regions = load_regions()
730 + for rid, reg in regions.regions.items():
731 + if rid != "global" and (ql in rid or ql in reg.name.lower()):
732 + live = next((r for r in (await _live("regions") or []) if r["id"] == rid), {})
733 + res.append({"type": "region", "id": rid, "label": reg.name, "href": f"/internet/{rid}", "pressure": live.get("pressure")})
734 + for c in await _live("countries") or []:
735 + if ql in c["cc"].lower() or ql in c["name"].lower():
736 + res.append({"type": "country", "id": c["cc"], "label": c["name"], "href": f"/country/{c['cc'].lower()}", "pressure": c.get("pressure")})
737 + for s in await list_services():
738 + if ql in s["slug"] or ql in s["name"].lower():
739 + live = next((x for x in (await _live("services") or []) if x["slug"] == s["slug"]), {})
740 + res.append({"type": "service", "id": s["slug"], "label": s["name"], "href": f"/service/{s['slug']}", "pressure": live.get("pressure")})
741 + for a in await _live("asns") or []:
742 + if ql == str(a["asn"]) or ql.replace("as", "") == str(a["asn"]) or (a.get("name") and ql in a["name"].lower()):
743 + res.append({"type": "asn", "id": str(a["asn"]), "label": f"AS{a['asn']} {a.get('name') or ''}".strip(), "href": f"/asn/{a['asn']}", "pressure": a.get("pressure")})
744 + if ql.isdigit() and not any(r["type"] == "asn" and r["id"] == ql for r in res):
745 + name = asndb.name(int(ql))
746 + if name:
747 + res.append({"type": "asn", "id": ql, "label": f"AS{ql} {name}", "href": f"/asn/{ql}", "pressure": None})
748 + for t in await list_targets(enabled_only=True):
749 + if ql in t["target_id"] or ql in t["name"].lower() or ql in t["hostname"]:
750 + res.append({"type": "target", "id": t["target_id"], "label": f"{t['name']} · {t['hostname']}", "href": f"/target/{t['target_id']}", "pressure": None})
751 + if len(res) > 40:
752 + break
753 + rows = await pg.fetch("SELECT slug, title, current_pressure FROM events WHERE title ILIKE $1 OR slug ILIKE $1 ORDER BY started_at DESC LIMIT 8", f"%{ql}%")
754 + for r in rows:
755 + res.append({"type": "incident", "id": r["slug"], "label": r["title"], "href": f"/event/{r['slug']}", "pressure": r1(r["current_pressure"])})
756 + return {"results": res[:40]}
added apps/api/src/internetpressure/asn.py +133 −0
@@ -0,0 +1,133 @@
1 +"""IP → ASN lookup from the open iptoasn.com dataset (ip2asn-combined.tsv.gz), cached on disk and refreshed daily.
2 +Also gives AS names. Works fully offline once the file has been downloaded once."""
3 +
4 +from __future__ import annotations
5 +
6 +import bisect
7 +import gzip
8 +import ipaddress
9 +import logging
10 +import time
11 +from pathlib import Path
12 +
13 +import httpx
14 +
15 +from .settings import get_settings
16 +
17 +log = logging.getLogger("ip.asn")
18 +URL = "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
19 +
20 +PRIVATE_NETS = [
21 + ipaddress.ip_network(n)
22 + for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
23 + "fc00::/7", "fe80::/10", "::1/128")
24 +]
25 +
26 +
27 +class AsnDB:
28 + def __init__(self) -> None:
29 + self.starts4: list[int] = []
30 + self.rows4: list[tuple[int, int, str]] = [] # (end, asn, name)
31 + self.starts6: list[int] = []
32 + self.rows6: list[tuple[int, int, str]] = []
33 + self.names: dict[int, str] = {}
34 + self.countries: dict[int, str] = {}
35 + self.loaded_at: float | None = None
36 + self.path: Path = get_settings().data_dir / "ip2asn-combined.tsv.gz"
37 +
38 + # ── loading ──
39 + def load(self) -> bool:
40 + if not self.path.exists():
41 + return False
42 + starts4, rows4, starts6, rows6 = [], [], [], []
43 + names: dict[int, str] = {}
44 + countries: dict[int, str] = {}
45 + with gzip.open(self.path, "rt", encoding="utf-8", errors="replace") as fh:
46 + for line in fh:
47 + parts = line.rstrip("\n").split("\t")
48 + if len(parts) < 5:
49 + continue
50 + try:
51 + asn = int(parts[2])
52 + except ValueError:
53 + continue
54 + if asn == 0:
55 + continue
56 + a = ipaddress.ip_address(parts[0])
57 + b = ipaddress.ip_address(parts[1])
58 + if a.version == 4:
59 + starts4.append(int(a))
60 + rows4.append((int(b), asn, parts[4]))
61 + else:
62 + starts6.append(int(a))
63 + rows6.append((int(b), asn, parts[4]))
64 + if asn not in names:
65 + names[asn] = parts[4]
66 + countries[asn] = parts[3]
67 + self.starts4, self.rows4, self.starts6, self.rows6 = starts4, rows4, starts6, rows6
68 + self.names, self.countries = names, countries
69 + self.loaded_at = time.time()
70 + log.info("ip2asn loaded: %d v4 ranges, %d v6 ranges, %d ASNs", len(rows4), len(rows6), len(names))
71 + return True
72 +
73 + async def refresh(self, *, max_age_s: int = 86400) -> bool:
74 + self.path.parent.mkdir(parents=True, exist_ok=True)
75 + fresh = self.path.exists() and (time.time() - self.path.stat().st_mtime) < max_age_s
76 + if not fresh:
77 + try:
78 + async with httpx.AsyncClient(timeout=120, follow_redirects=True) as c:
79 + r = await c.get(URL, headers={"User-Agent": "InternetPressure/0.1 (+https://www.internetpressure.io)"})
80 + r.raise_for_status()
81 + tmp = self.path.with_suffix(".tmp")
82 + tmp.write_bytes(r.content)
83 + tmp.replace(self.path)
84 + log.info("ip2asn downloaded (%d bytes)", len(r.content))
85 + except Exception as exc: # noqa: BLE001
86 + log.warning("ip2asn download failed: %s", exc)
87 + if self.loaded_at is None or not fresh:
88 + return self.load()
89 + return True
90 +
91 + # ── lookup ──
92 + def lookup(self, ip: str | None) -> tuple[int | None, str | None]:
93 + if not ip or ip == "*":
94 + return None, None
95 + try:
96 + addr = ipaddress.ip_address(ip)
97 + except ValueError:
98 + return None, None
99 + if any(addr in n for n in PRIVATE_NETS):
100 + return None, None
101 + starts, rows = (self.starts4, self.rows4) if addr.version == 4 else (self.starts6, self.rows6)
102 + if not starts:
103 + return None, None
104 + i = bisect.bisect_right(starts, int(addr)) - 1
105 + if i < 0:
106 + return None, None
107 + end, asn, name = rows[i]
108 + if int(addr) > end:
109 + return None, None
110 + return asn, name
111 +
112 + def is_private(self, ip: str | None) -> bool:
113 + try:
114 + return any(ipaddress.ip_address(ip) in n for n in PRIVATE_NETS) if ip and ip != "*" else False
115 + except ValueError:
116 + return False
117 +
118 + def name(self, asn: int | None) -> str | None:
119 + return self.names.get(int(asn)) if asn else None
120 +
121 + def country(self, asn: int | None) -> str | None:
122 + return self.countries.get(int(asn)) if asn else None
123 +
124 + def asn_path(self, hop_ips: list[str]) -> list[int]:
125 + out: list[int] = []
126 + for ip in hop_ips:
127 + asn, _ = self.lookup(ip)
128 + if asn and (not out or out[-1] != asn):
129 + out.append(asn)
130 + return out
131 +
132 +
133 +asndb = AsnDB()
added apps/api/src/internetpressure/bgp/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""BGP ingestion (RIPE RIS Live) → normalised events, 10 s per-collector aggregates, per-origin minutes."""
added apps/api/src/internetpressure/bgp/rislive.py +231 −0
@@ -0,0 +1,231 @@
1 +"""RIPE RIS Live ingestor (wss://ris-live.ripe.net/v1/ws/).
2 +
3 +One websocket, subscribed to UPDATE messages from all (or a configured subset of) collectors. Every message is
4 +normalised into announcement/withdrawal events; we keep:
5 + * raw events in ClickHouse `bgp_events` (3-day TTL, optionally sampled for announcements),
6 + * 10-second aggregates per collector in `bgp_stats_10s` (announcements, withdrawals, unique prefixes/origins,
7 + origin changes = prefixes whose origin ASN differs from the last origin we saw for it, peers, avg path length),
8 + * per-origin per-minute counters in `bgp_origin_1m`,
9 + * a live rate snapshot in Redis `ip:bgp:rate` (TTL 60 s) used by the engine and the ticker.
10 +Reconnects forever with backoff. Never trusts a single message; all scoring happens in the engine against baselines.
11 +"""
12 +
13 +from __future__ import annotations
14 +
15 +import asyncio
16 +import logging
17 +import time
18 +from collections import defaultdict
19 +from typing import Any
20 +
21 +import websockets
22 +
23 +from ..db import ch, rds
24 +from ..settings import get_settings
25 +from ..util import ch_ts, dumps, iso, loads, utcnow
26 +
27 +log = logging.getLogger("ip.bgp")
28 +
29 +COLLECTOR_LOCATIONS = {
30 + "rrc00": "Amsterdam (multihop)", "rrc01": "London (LINX)", "rrc03": "Amsterdam (AMS-IX)", "rrc04": "Geneva (CIXP)",
31 + "rrc05": "Vienna (VIX)", "rrc06": "Otemachi (DIX-IE)", "rrc07": "Stockholm (Netnod)", "rrc10": "Milan (MIX)",
32 + "rrc11": "New York (NYIIX)", "rrc12": "Frankfurt (DE-CIX)", "rrc13": "Moscow (MSK-IX)", "rrc14": "Palo Alto (PAIX)",
33 + "rrc15": "São Paulo (PTT-SP)", "rrc16": "Miami (NOTA)", "rrc18": "Barcelona (CATNIX)", "rrc19": "Johannesburg (NAP Africa)",
34 + "rrc20": "Zürich (SwissIX)", "rrc21": "Paris (France-IX)", "rrc22": "Bucharest (InterLAN)", "rrc23": "Singapore (Equinix)",
35 + "rrc24": "Montevideo (LACNIC)", "rrc25": "Amsterdam (multihop)", "rrc26": "Dubai (UAE-IX)",
36 +}
37 +
38 +
39 +class Window:
40 + def __init__(self) -> None:
41 + self.a = 0
42 + self.w = 0
43 + self.prefixes: set[str] = set()
44 + self.origins: set[int] = set()
45 + self.origin_changes = 0
46 + self.peers: set[str] = set()
47 + self.path_len_sum = 0
48 + self.path_len_n = 0
49 +
50 +
51 +class Ingestor:
52 + def __init__(self) -> None:
53 + s = get_settings()
54 + self.url = s.ris_live_url
55 + self.collectors = [c.strip() for c in s.ris_collectors.split(",") if c.strip()]
56 + self.store_raw = s.bgp_store_raw
57 + self.sample = max(1, s.bgp_raw_sample)
58 + self.windows: dict[str, Window] = defaultdict(Window)
59 + self.window_start = int(time.time() // 10 * 10)
60 + self.origin_minute: dict[int, dict[str, Any]] = defaultdict(lambda: {"a": 0, "w": 0, "prefixes": set()})
61 + self.minute_start = int(time.time() // 60 * 60)
62 + self.last_origin: dict[str, int] = {} # prefix → origin (bounded)
63 + self.raw_buf: list[dict[str, Any]] = []
64 + self.msg_count = 0
65 + self.reconnects = 0
66 + self.last_msg = 0.0
67 + self._i = 0
68 +
69 + # ── message handling ──
70 + def handle(self, msg: dict[str, Any]) -> None:
71 + if msg.get("type") != "ris_message":
72 + return
73 + d = msg.get("data") or {}
74 + if d.get("type") != "UPDATE":
75 + return
76 + self.msg_count += 1
77 + self.last_msg = time.time()
78 + collector = (d.get("host") or "unknown").split(".")[0]
79 + peer = f"{d.get('peer')}|{d.get('peer_asn')}"
80 + ts = float(d.get("timestamp") or time.time())
81 + path = d.get("path") or []
82 + origin = 0
83 + if path:
84 + last = path[-1]
85 + if isinstance(last, list):
86 + last = last[0] if last else 0
87 + try:
88 + origin = int(last)
89 + except (TypeError, ValueError):
90 + origin = 0
91 + plen = len(path)
92 + w = self.windows[collector]
93 + w.peers.add(peer)
94 + try:
95 + peer_asn = int(d.get("peer_asn") or 0)
96 + except (TypeError, ValueError):
97 + peer_asn = 0
98 + flat_path = [int(x if not isinstance(x, list) else (x[0] if x else 0)) for x in path][:64] if path else []
99 +
100 + for ann in d.get("announcements") or []:
101 + for prefix in ann.get("prefixes") or []:
102 + w.a += 1
103 + w.prefixes.add(prefix)
104 + if origin:
105 + w.origins.add(origin)
106 + prev = self.last_origin.get(prefix)
107 + if prev is not None and prev != origin:
108 + w.origin_changes += 1
109 + self.last_origin[prefix] = origin
110 + om = self.origin_minute[origin]
111 + om["a"] += 1
112 + om["prefixes"].add(prefix)
113 + if plen:
114 + w.path_len_sum += plen
115 + w.path_len_n += 1
116 + self._i += 1
117 + if self.store_raw and (self._i % self.sample == 0):
118 + self.raw_buf.append({"ts": ch_ts(_dt(ts)), "collector": collector, "peer_asn": peer_asn, "prefix": prefix,
119 + "origin_asn": origin, "event_type": "announcement", "path_len": min(plen, 255), "as_path": flat_path})
120 + for prefix in d.get("withdrawals") or []:
121 + w.w += 1
122 + w.prefixes.add(prefix)
123 + prev = self.last_origin.get(prefix)
124 + if prev:
125 + om = self.origin_minute[prev]
126 + om["w"] += 1
127 + if self.store_raw:
128 + self.raw_buf.append({"ts": ch_ts(_dt(ts)), "collector": collector, "peer_asn": peer_asn, "prefix": prefix,
129 + "origin_asn": prev or 0, "event_type": "withdrawal", "path_len": 0, "as_path": []})
130 + if len(self.last_origin) > 1_500_000: # bounded memory
131 + for k in list(self.last_origin)[:300_000]:
132 + self.last_origin.pop(k, None)
133 +
134 + # ── periodic flush ──
135 + async def flush(self) -> None:
136 + now = int(time.time())
137 + cur_win = now // 10 * 10
138 + if cur_win > self.window_start:
139 + rows = []
140 + rate: dict[str, Any] = {"ts": iso(utcnow()), "per_collector": {}}
141 + tot_a = tot_w = 0
142 + for c, w in self.windows.items():
143 + rows.append({"ts": _dt(self.window_start).strftime("%Y-%m-%d %H:%M:%S"), "collector": c, "announcements": w.a,
144 + "withdrawals": w.w, "unique_prefixes": len(w.prefixes), "unique_origins": len(w.origins),
145 + "origin_changes": w.origin_changes, "peers": min(len(w.peers), 65535),
146 + "avg_path_len": (w.path_len_sum / w.path_len_n) if w.path_len_n else 0.0})
147 + rate["per_collector"][c] = {"a": w.a, "w": w.w, "peers": len(w.peers)}
148 + tot_a += w.a
149 + tot_w += w.w
150 + span = max(10, cur_win - self.window_start)
151 + rate.update({"announcements_per_s": round(tot_a / span, 2), "withdrawals_per_s": round(tot_w / span, 2),
152 + "updates_per_s": round((tot_a + tot_w) / span, 2), "collectors": len(self.windows),
153 + "messages_total": self.msg_count, "reconnects": self.reconnects})
154 + self.windows = defaultdict(Window)
155 + self.window_start = cur_win
156 + try:
157 + if rows:
158 + await ch.insert("bgp_stats_10s", rows)
159 + await rds.set_json("ip:bgp:rate", rate, ex=60)
160 + await rds.incr_minute("bgp", utcnow().strftime("%Y%m%d%H%M"), tot_a + tot_w)
161 + await rds.incr_minute("bgpw", utcnow().strftime("%Y%m%d%H%M"), tot_w)
162 + except Exception as exc: # noqa: BLE001
163 + log.warning("bgp stats flush failed: %s", exc)
164 + cur_min = now // 60 * 60
165 + if cur_min > self.minute_start and self.origin_minute:
166 + rows = [{"ts": _dt(self.minute_start).strftime("%Y-%m-%d %H:%M:%S"), "origin_asn": o, "announcements": v["a"],
167 + "withdrawals": v["w"], "prefixes": len(v["prefixes"])} for o, v in self.origin_minute.items() if o]
168 + self.origin_minute = defaultdict(lambda: {"a": 0, "w": 0, "prefixes": set()})
169 + self.minute_start = cur_min
170 + try:
171 + await ch.insert("bgp_origin_1m", rows)
172 + except Exception as exc: # noqa: BLE001
173 + log.warning("bgp origin flush failed: %s", exc)
174 + if self.raw_buf and (len(self.raw_buf) >= 5000 or True):
175 + buf, self.raw_buf = self.raw_buf, []
176 + try:
177 + await ch.insert("bgp_events", buf)
178 + except Exception as exc: # noqa: BLE001
179 + log.warning("bgp raw flush failed (%d rows dropped): %s", len(buf), exc)
180 +
181 + async def flusher(self) -> None:
182 + while True:
183 + await asyncio.sleep(2)
184 + try:
185 + await self.flush()
186 + except Exception as exc: # noqa: BLE001
187 + log.exception("flush error: %s", exc)
188 +
189 + # ── connection ──
190 + async def run(self) -> None:
191 + backoff = 2
192 + while True:
193 + try:
194 + log.info("connecting to RIS Live %s (collectors: %s)", self.url, self.collectors or "all")
195 + async with websockets.connect(self.url, max_size=8 * 1024 * 1024, ping_interval=20, ping_timeout=20,
196 + open_timeout=20, compression=None) as ws:
197 + subs = self.collectors or [None]
198 + for host in subs:
199 + params: dict[str, Any] = {"type": "UPDATE", "moreSpecific": False}
200 + if host:
201 + params["host"] = host
202 + await ws.send(dumps({"type": "ris_subscribe", "data": params}).decode())
203 + backoff = 2
204 + async for raw in ws:
205 + try:
206 + self.handle(loads(raw))
207 + except Exception as exc: # noqa: BLE001
208 + log.debug("bad message: %s", exc)
209 + except asyncio.CancelledError:
210 + raise
211 + except Exception as exc: # noqa: BLE001
212 + self.reconnects += 1
213 + log.warning("RIS Live connection lost (%s); reconnecting in %ss", exc, backoff)
214 + await asyncio.sleep(backoff)
215 + backoff = min(backoff * 2, 120)
216 +
217 +
218 +def _dt(ts: float): # type: ignore[no-untyped-def]
219 + from datetime import UTC, datetime
220 +
221 + return datetime.fromtimestamp(ts, UTC)
222 +
223 +
224 +async def main() -> None:
225 + await ch.migrate()
226 + ing = Ingestor()
227 + flusher = asyncio.create_task(ing.flusher())
228 + try:
229 + await ing.run()
230 + finally:
231 + flusher.cancel()
added apps/api/src/internetpressure/cli.py +168 −0
@@ -0,0 +1,168 @@
1 +"""`ip` command line: migrate, seed, api, engine, bgp, corroboration, probe-key, replay, status."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import logging
7 +import sys
8 +
9 +import typer
10 +
11 +from . import __version__
12 +from .settings import get_settings
13 +
14 +app = typer.Typer(help="InternetPressure.io backend", no_args_is_help=True, add_completion=False)
15 +
16 +
17 +def _logging() -> None:
18 + s = get_settings()
19 + logging.basicConfig(
20 + level=getattr(logging, s.log_level.upper(), logging.INFO),
21 + format="%(asctime)s %(levelname)s %(name)s %(message)s",
22 + stream=sys.stdout,
23 + )
24 + logging.getLogger("httpx").setLevel(logging.WARNING)
25 + logging.getLogger("websockets").setLevel(logging.WARNING)
26 +
27 +
28 +@app.command()
29 +def version() -> None:
30 + typer.echo(__version__)
31 +
32 +
33 +@app.command()
34 +def migrate() -> None:
35 + """Apply Postgres + ClickHouse migrations."""
36 + _logging()
37 +
38 + async def run() -> None:
39 + from .db import ch, pg
40 +
41 + a = await pg.migrate()
42 + b = await ch.migrate()
43 + typer.echo(f"postgres: {a or 'up to date'} · clickhouse: {b or 'up to date'}")
44 + await pg.close()
45 + await ch.close()
46 +
47 + asyncio.run(run())
48 +
49 +
50 +@app.command()
51 +def seed(targets: bool = True, services: bool = True, probes: bool = True) -> None:
52 + """Load data/targets, data/seed/services.yaml and data/seed/probes.yaml into Postgres (idempotent)."""
53 + _logging()
54 +
55 + async def run() -> None:
56 + from .db import pg, rds
57 + from .registry import seed as do_seed
58 +
59 + counts = await do_seed(targets=targets, services=services, probes=probes)
60 + typer.echo(f"seeded {counts}")
61 + await pg.close()
62 + await rds.close()
63 +
64 + asyncio.run(run())
65 +
66 +
67 +@app.command("probe-key")
68 +def probe_key(probe_id: str, rotate: bool = False) -> None:
69 + """Print the HMAC key of a probe (creating it from data/seed/probes.yaml if needed); --rotate issues a new one."""
70 + _logging()
71 +
72 + async def run() -> None:
73 + from .db import pg
74 + from .registry import get_probe, rotate_probe_key
75 +
76 + p = await get_probe(probe_id, with_key=True)
77 + if not p:
78 + typer.echo(f"unknown probe {probe_id} (run `ip seed` or create it via the admin API)", err=True)
79 + raise typer.Exit(1)
80 + key = await rotate_probe_key(probe_id) if rotate else p["key"]
81 + typer.echo(key)
82 + await pg.close()
83 +
84 + asyncio.run(run())
85 +
86 +
87 +@app.command()
88 +def api() -> None:
89 + """Run the HTTP API (public + ingest + admin + SSE)."""
90 + _logging()
91 + import uvicorn
92 +
93 + s = get_settings()
94 + uvicorn.run("internetpressure.api.app:create_app", factory=True, host=s.api_host, port=s.api_port,
95 + log_level=s.log_level.lower(), proxy_headers=True, forwarded_allow_ips="*", access_log=False)
96 +
97 +
98 +@app.command()
99 +def engine(once: bool = False) -> None:
100 + """Run the pressure engine loop (baselines, scores, events, fronts, live publish)."""
101 + _logging()
102 + from .engine.loop import main
103 +
104 + asyncio.run(main(once=once))
105 +
106 +
107 +@app.command()
108 +def bgp() -> None:
109 + """Run the RIPE RIS Live ingestor."""
110 + _logging()
111 + from .bgp.rislive import main
112 +
113 + asyncio.run(main())
114 +
115 +
116 +@app.command()
117 +def corroboration(once: bool = False) -> None:
118 + """Poll optional public status pages (never a scoring dependency)."""
119 + _logging()
120 + from .corroboration.runner import main
121 +
122 + asyncio.run(main(once=once))
123 +
124 +
125 +@app.command()
126 +def status() -> None:
127 + """Quick health of the stores and the live state."""
128 + _logging()
129 +
130 + async def run() -> None:
131 + from .db import ch, pg, rds
132 +
133 + typer.echo(f"postgres: {await pg.healthy()} clickhouse: {await ch.healthy()} redis: {await rds.healthy()}")
134 + g = await rds.get_json("ip:live:global")
135 + if g:
136 + typer.echo(f"global pressure: {g.get('pressure')} ({g.get('level')}) at {g.get('ts')} status={g.get('internal_status')}")
137 + else:
138 + typer.echo("no live state yet")
139 + await pg.close()
140 + await ch.close()
141 + await rds.close()
142 +
143 + asyncio.run(run())
144 +
145 +
146 +@app.command()
147 +def replay(start: str, end: str, weights: str = "") -> None:
148 + """Recompute the global index over [start, end] from stored component history with alternative weights
149 + (JSON string like '{"routing":0.3,...}'). Prints original vs replayed per step."""
150 + _logging()
151 +
152 + async def run() -> None:
153 + import json
154 +
155 + from .db import ch
156 + from .engine.replay import replay_range
157 +
158 + w = json.loads(weights) if weights else None
159 + rows = await replay_range(start, end, w)
160 + for r in rows:
161 + typer.echo(f"{r['ts']} {r['pressure_original']:6.1f} → {r['pressure_replayed']:6.1f}")
162 + await ch.close()
163 +
164 + asyncio.run(run())
165 +
166 +
167 +if __name__ == "__main__":
168 + app()
added apps/api/src/internetpressure/config.py +140 −0
@@ -0,0 +1,140 @@
1 +"""Scoring configuration: packages/config/pressure.yaml, optionally overridden by the Postgres `config` table
2 +(edited from /admin). Loaded fresh every engine cycle so weight changes apply without a restart."""
3 +
4 +from __future__ import annotations
5 +
6 +import copy
7 +import time
8 +from dataclasses import dataclass, field
9 +from pathlib import Path
10 +from typing import Any
11 +
12 +import yaml
13 +
14 +from .settings import get_settings
15 +
16 +COMPONENT_IDS = ("routing", "latency", "dns", "availability", "http_tls", "path", "corroboration")
17 +COMPONENT_LABELS = {
18 + "routing": "Routing",
19 + "latency": "Latency",
20 + "dns": "DNS",
21 + "availability": "Availability",
22 + "http_tls": "HTTP/TLS",
23 + "path": "Path",
24 + "corroboration": "Corroboration",
25 +}
26 +
27 +
28 +class ConfigError(ValueError):
29 + pass
30 +
31 +
32 +@dataclass
33 +class PressureConfig:
34 + raw: dict[str, Any]
35 + weights: dict[str, float]
36 + levels: list[dict[str, Any]]
37 + engine: dict[str, Any]
38 + components: dict[str, Any]
39 + importance_weights: dict[int, float]
40 + events: dict[str, Any]
41 + fronts: dict[str, Any]
42 + scheduler: dict[str, Any]
43 + version: int = 1
44 + updated_at: str | None = None
45 + source: str = "file"
46 + loaded_at: float = field(default_factory=time.time)
47 +
48 + def level_for(self, value: float | None) -> tuple[str, str]:
49 + if value is None:
50 + return "unknown", "Unknown"
51 + for lv in self.levels:
52 + if value <= float(lv["max"]):
53 + return lv["id"], lv["label"]
54 + last = self.levels[-1]
55 + return last["id"], last["label"]
56 +
57 + def importance_weight(self, importance: int | None) -> float:
58 + return self.importance_weights.get(int(importance or 3), 1.0)
59 +
60 + def signal_weights(self, component: str) -> dict[str, float]:
61 + comp = self.components.get(component) or {}
62 + return {s["id"]: float(s["weight"]) for s in comp.get("signals", [])}
63 +
64 + def signal_label(self, component: str, signal_id: str) -> str:
65 + for s in (self.components.get(component) or {}).get("signals", []):
66 + if s["id"] == signal_id:
67 + return s["label"]
68 + return signal_id
69 +
70 + def public_dict(self) -> dict[str, Any]:
71 + return {
72 + "version": self.version,
73 + "updated_at": self.updated_at,
74 + "source": self.source,
75 + "weights": self.weights,
76 + "levels": self.levels,
77 + "engine": self.engine,
78 + "components": self.components,
79 + "importance_weights": {str(k): v for k, v in self.importance_weights.items()},
80 + "events": self.events,
81 + "fronts": self.fronts,
82 + "scheduler": self.scheduler,
83 + }
84 +
85 +
86 +def validate_config(raw: dict[str, Any]) -> None:
87 + weights = raw.get("pressure_weights") or {}
88 + missing = [c for c in COMPONENT_IDS if c not in weights]
89 + if missing:
90 + raise ConfigError(f"pressure_weights missing components: {missing}")
91 + total = sum(float(v) for v in weights.values())
92 + if abs(total - 1.0) > 0.001:
93 + raise ConfigError(f"pressure_weights must sum to 1.0 (got {total:.4f})")
94 + if any(float(v) < 0 for v in weights.values()):
95 + raise ConfigError("pressure_weights must be non-negative")
96 + levels = raw.get("levels") or []
97 + if not levels or float(levels[-1]["max"]) != 100:
98 + raise ConfigError("levels must end at max 100")
99 + prev = -1.0
100 + for lv in levels:
101 + if float(lv["max"]) <= prev:
102 + raise ConfigError("levels must be strictly increasing")
103 + prev = float(lv["max"])
104 + for comp, spec in (raw.get("components") or {}).items():
105 + sw = sum(float(s["weight"]) for s in spec.get("signals", []))
106 + if spec.get("signals") and abs(sw - 1.0) > 0.001:
107 + raise ConfigError(f"component {comp} signal weights must sum to 1.0 (got {sw:.3f})")
108 +
109 +
110 +def build_config(raw: dict[str, Any], *, source: str = "file", updated_at: str | None = None) -> PressureConfig:
111 + validate_config(raw)
112 + return PressureConfig(
113 + raw=raw,
114 + weights={k: float(v) for k, v in raw["pressure_weights"].items()},
115 + levels=list(raw["levels"]),
116 + engine=dict(raw.get("engine") or {}),
117 + components=dict(raw.get("components") or {}),
118 + importance_weights={int(k): float(v) for k, v in (raw.get("importance_weights") or {}).items()},
119 + events=dict(raw.get("events") or {}),
120 + fronts=dict(raw.get("fronts") or {}),
121 + scheduler=dict(raw.get("scheduler") or {}),
122 + version=int(raw.get("version") or 1),
123 + updated_at=updated_at,
124 + source=source,
125 + )
126 +
127 +
128 +def load_file_config(path: Path | None = None) -> PressureConfig:
129 + p = path or get_settings().config_path
130 + with open(p, encoding="utf-8") as fh:
131 + raw = yaml.safe_load(fh)
132 + return build_config(raw, source="file")
133 +
134 +
135 +def merged(raw_file: dict[str, Any], override: dict[str, Any] | None) -> dict[str, Any]:
136 + """Postgres override replaces whole top-level sections (weights, levels, engine, …) it defines."""
137 + out = copy.deepcopy(raw_file)
138 + for k, v in (override or {}).items():
139 + out[k] = v
140 + return out
added apps/api/src/internetpressure/corroboration/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Optional external corroboration connectors (public status pages). Never a scoring dependency (weight 0.05)."""
added apps/api/src/internetpressure/corroboration/connectors.py +139 −0
@@ -0,0 +1,139 @@
1 +"""Connector interface + implementations. No business logic here: each returns a normalised VendorStatus.
2 +
3 + class Connector: id, name, kind ; async fetch(url) -> VendorStatus ; health() from last run
4 +"""
5 +
6 +from __future__ import annotations
7 +
8 +import logging
9 +from dataclasses import dataclass, field
10 +from typing import Any
11 +
12 +import httpx
13 +
14 +log = logging.getLogger("ip.corroboration")
15 +UA = "InternetPressure/0.1 (+https://www.internetpressure.io; corroboration connector)"
16 +
17 +
18 +@dataclass
19 +class VendorStatus:
20 + indicator: str # none | minor | major | critical | unknown
21 + incidents: int
22 + titles: list[str] = field(default_factory=list)
23 + source: str = ""
24 + url: str = ""
25 + raw: dict[str, Any] | None = None
26 + ok: bool = True
27 + error: str | None = None
28 +
29 +
30 +async def _get(url: str, *, json: bool = True) -> Any:
31 + async with httpx.AsyncClient(timeout=15, follow_redirects=True, headers={"User-Agent": UA}) as c:
32 + r = await c.get(url)
33 + r.raise_for_status()
34 + return r.json() if json else r.text
35 +
36 +
37 +async def statuspage(url: str) -> VendorStatus:
38 + """Atlassian Statuspage `/api/v2/summary.json` (Cloudflare, GitHub, Stripe, Discord, …)."""
39 + data = await _get(url)
40 + st = (data.get("status") or {}).get("indicator") or "none"
41 + incs = [i for i in (data.get("incidents") or []) if (i.get("status") or "") not in ("resolved", "postmortem")]
42 + titles = [i.get("name", "") for i in incs][:5]
43 + src = (data.get("page") or {}).get("url") or url
44 + return VendorStatus(indicator=st if st in ("none", "minor", "major", "critical") else "unknown", incidents=len(incs),
45 + titles=titles, source=src.replace("https://", "").rstrip("/"), url=src,
46 + raw={"status": data.get("status"), "incidents": [{"name": i.get("name"), "impact": i.get("impact"),
47 + "status": i.get("status"), "updated_at": i.get("updated_at")} for i in incs][:10]})
48 +
49 +
50 +async def gcp_json(url: str) -> VendorStatus:
51 + data = await _get(url)
52 + active = [i for i in data if not i.get("end")]
53 + worst = "none"
54 + for i in active:
55 + sev = (i.get("severity") or "").lower()
56 + if sev == "high":
57 + worst = "major"
58 + elif sev == "medium" and worst == "none":
59 + worst = "minor"
60 + return VendorStatus(indicator=worst, incidents=len(active), titles=[i.get("external_desc", "")[:120] for i in active][:5],
61 + source="status.cloud.google.com", url="https://status.cloud.google.com/",
62 + raw={"active": [{"id": i.get("id"), "severity": i.get("severity"), "begin": i.get("begin")} for i in active][:10]})
63 +
64 +
65 +async def rss_feed(url: str, source: str, page_url: str) -> VendorStatus:
66 + import feedparser
67 +
68 + text = await _get(url, json=False)
69 + feed = feedparser.parse(text)
70 + from datetime import UTC, datetime, timedelta
71 + import time as _t
72 +
73 + recent = []
74 + for e in feed.entries[:50]:
75 + pub = e.get("published_parsed") or e.get("updated_parsed")
76 + if pub and datetime.fromtimestamp(_t.mktime(pub), UTC) >= datetime.now(UTC) - timedelta(hours=6):
77 + title = e.get("title", "")
78 + if "resolved" in title.lower():
79 + continue
80 + recent.append(title)
81 + ind = "none" if not recent else ("minor" if len(recent) <= 2 else "major")
82 + return VendorStatus(indicator=ind, incidents=len(recent), titles=recent[:5], source=source, url=page_url,
83 + raw={"entries": recent[:10]})
84 +
85 +
86 +async def statusio(url: str) -> VendorStatus:
87 + """status.io public API `/1.0/status/<page_id>` (Let's Encrypt)."""
88 + data = (await _get(url)).get("result") or {}
89 + overall = (data.get("status_overall") or {}).get("status", "Operational")
90 + incs = data.get("incidents") or []
91 + ind = "none" if overall.lower() == "operational" else ("major" if "outage" in overall.lower() else "minor")
92 + return VendorStatus(indicator=ind, incidents=len(incs), titles=[i.get("name", "") for i in incs][:5],
93 + source=url.split("/")[2], url="https://" + url.split("/")[2] + "/", raw={"status_overall": overall})
94 +
95 +
96 +async def slack_json(url: str) -> VendorStatus:
97 + data = await _get(url)
98 + incs = data.get("active_incidents") or []
99 + status = (data.get("status") or "ok").lower()
100 + ind = "none" if status == "ok" and not incs else ("minor" if len(incs) <= 1 else "major")
101 + return VendorStatus(indicator=ind, incidents=len(incs), titles=[i.get("title", "") for i in incs][:5],
102 + source="slack-status.com", url="https://slack-status.com/", raw={"status": status})
103 +
104 +
105 +async def heroku_v4(url: str) -> VendorStatus:
106 + data = await _get(url)
107 + incs = data.get("incidents") or []
108 + worst = "none"
109 + for s in data.get("status") or []:
110 + st = (s.get("status") or "green").lower()
111 + if st == "red":
112 + worst = "major"
113 + elif st == "yellow" and worst == "none":
114 + worst = "minor"
115 + return VendorStatus(indicator=worst, incidents=len(incs), titles=[i.get("title", "") for i in incs][:5],
116 + source="status.heroku.com", url="https://status.heroku.com/", raw={"status": data.get("status")})
117 +
118 +
119 +async def fetch(kind: str, url: str) -> VendorStatus:
120 + try:
121 + if kind == "statuspage":
122 + return await statuspage(url)
123 + if kind == "gcp_json":
124 + return await gcp_json(url)
125 + if kind == "aws_rss":
126 + return await rss_feed(url, "health.aws.amazon.com", "https://health.aws.amazon.com/health/status")
127 + if kind == "azure_rss":
128 + return await rss_feed(url, "azure.status.microsoft", "https://azure.status.microsoft/")
129 + if kind == "slack_json":
130 + return await slack_json(url)
131 + if kind == "statusio":
132 + return await statusio(url)
133 + if kind == "heroku_v4":
134 + return await heroku_v4(url)
135 + if kind == "statuspage" or url.endswith("summary.json"):
136 + return await statuspage(url)
137 + return VendorStatus(indicator="unknown", incidents=0, ok=False, error=f"unknown connector kind {kind}", url=url)
138 + except Exception as exc: # noqa: BLE001
139 + return VendorStatus(indicator="unknown", incidents=0, ok=False, error=str(exc)[:200], url=url)
added apps/api/src/internetpressure/corroboration/runner.py +55 −0
@@ -0,0 +1,55 @@
1 +"""Polls every service with a `status` connector spec every 60 s and stores the normalised result in Postgres
2 +`vendor_status` (+ raw provenance). Failures are recorded, never raised into scoring."""
3 +
4 +from __future__ import annotations
5 +
6 +import asyncio
7 +import logging
8 +
9 +from ..db import pg
10 +from ..registry import list_services
11 +from ..util import dumps_str
12 +from .connectors import fetch
13 +
14 +log = logging.getLogger("ip.corroboration")
15 +
16 +
17 +async def run_once() -> int:
18 + services = [s for s in await list_services() if s.get("status")]
19 + sem = asyncio.Semaphore(6)
20 +
21 + async def one(svc): # type: ignore[no-untyped-def]
22 + spec = svc["status"]
23 + kind = spec.get("kind", "statuspage")
24 + url = spec.get("url")
25 + if kind == "statuspage" and "status.heroku.com/api/v4" in (url or ""):
26 + kind = "heroku_v4"
27 + async with sem:
28 + vs = await fetch(kind, url)
29 + await pg.execute(
30 + """INSERT INTO vendor_status(service_slug,indicator,incidents,titles,source,url,raw,ok,error,checked_at)
31 + VALUES($1,$2,$3,$4::jsonb,$5,$6,$7::jsonb,$8,$9,now())
32 + ON CONFLICT (service_slug) DO UPDATE SET indicator=EXCLUDED.indicator, incidents=EXCLUDED.incidents,
33 + titles=EXCLUDED.titles, source=EXCLUDED.source, url=EXCLUDED.url, raw=EXCLUDED.raw, ok=EXCLUDED.ok,
34 + error=EXCLUDED.error, checked_at=now()""",
35 + svc["slug"], vs.indicator, vs.incidents, dumps_str(vs.titles), vs.source, vs.url or url,
36 + dumps_str(vs.raw) if vs.raw else None, vs.ok, vs.error,
37 + )
38 + return vs
39 +
40 + results = await asyncio.gather(*(one(s) for s in services), return_exceptions=True)
41 + ok = sum(1 for r in results if not isinstance(r, Exception) and r.ok)
42 + active = sum(1 for r in results if not isinstance(r, Exception) and r.ok and r.indicator not in ("none", "unknown"))
43 + log.info("corroboration: %d/%d connectors ok, %d providers reporting incidents", ok, len(services), active)
44 + return ok
45 +
46 +
47 +async def main(*, once: bool = False) -> None:
48 + while True:
49 + try:
50 + await run_once()
51 + except Exception as exc: # noqa: BLE001
52 + log.exception("corroboration run failed: %s", exc)
53 + if once:
54 + break
55 + await asyncio.sleep(60)
added apps/api/src/internetpressure/db/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Store clients: Postgres (registry/config/incidents), ClickHouse (telemetry/history), Redis (live state)."""
added apps/api/src/internetpressure/db/ch.py +136 −0
@@ -0,0 +1,136 @@
1 +"""ClickHouse over its HTTP interface (httpx, async). JSONEachRow in and out — no extra driver.
2 +
3 +Usage:
4 + rows = await ch.query("SELECT … FORMAT JSONEachRow") # list[dict]
5 + await ch.insert("measurements", [ {...}, {...} ]) # JSONEachRow batch insert
6 +"""
7 +
8 +from __future__ import annotations
9 +
10 +import logging
11 +from pathlib import Path
12 +from typing import Any
13 +
14 +import httpx
15 +
16 +from ..settings import get_settings
17 +from ..util import dumps, loads
18 +
19 +log = logging.getLogger("ip.ch")
20 +MIGRATIONS_DIR = Path(__file__).parent / "migrations" / "ch"
21 +
22 +_client: httpx.AsyncClient | None = None
23 +
24 +
25 +def _c() -> httpx.AsyncClient:
26 + global _client
27 + if _client is None:
28 + s = get_settings()
29 + _client = httpx.AsyncClient(
30 + base_url=s.ch_url,
31 + timeout=httpx.Timeout(60.0, connect=5.0),
32 + headers={"X-ClickHouse-User": s.ch_user, "X-ClickHouse-Key": s.ch_password},
33 + limits=httpx.Limits(max_connections=16),
34 + )
35 + return _client
36 +
37 +
38 +async def close() -> None:
39 + global _client
40 + if _client is not None:
41 + await _client.aclose()
42 + _client = None
43 +
44 +
45 +class CHError(RuntimeError):
46 + pass
47 +
48 +
49 +async def raw(sql: str, *, database: str | None = None, body: bytes | None = None, params: dict | None = None) -> bytes:
50 + s = get_settings()
51 + q: dict[str, Any] = {"database": database or s.ch_db, "date_time_output_format": "iso"}
52 + if params:
53 + for k, v in params.items():
54 + q[f"param_{k}"] = v
55 + if body is None:
56 + r = await _c().post("/", params=q, content=sql.encode())
57 + else:
58 + q["query"] = sql
59 + r = await _c().post("/", params=q, content=body, headers={"Content-Type": "application/octet-stream"})
60 + if r.status_code != 200:
61 + raise CHError(f"ClickHouse {r.status_code}: {r.text[:800]}\n-- {sql[:400]}")
62 + return r.content
63 +
64 +
65 +async def query(sql: str, params: dict | None = None) -> list[dict[str, Any]]:
66 + if "FORMAT" not in sql.upper():
67 + sql = sql.rstrip().rstrip(";") + " FORMAT JSONEachRow"
68 + out = await raw(sql, params=params)
69 + if not out.strip():
70 + return []
71 + return [loads(line) for line in out.splitlines() if line.strip()]
72 +
73 +
74 +async def query_one(sql: str, params: dict | None = None) -> dict[str, Any] | None:
75 + rows = await query(sql, params)
76 + return rows[0] if rows else None
77 +
78 +
79 +async def execute(sql: str, *, database: str | None = None) -> None:
80 + await raw(sql, database=database)
81 +
82 +
83 +async def insert(table: str, rows: list[dict[str, Any]]) -> int:
84 + if not rows:
85 + return 0
86 + body = b"\n".join(dumps(r) for r in rows) + b"\n"
87 + await raw(f"INSERT INTO {table} FORMAT JSONEachRow", body=body)
88 + return len(rows)
89 +
90 +
91 +async def healthy() -> bool:
92 + try:
93 + r = await query("SELECT 1 AS ok")
94 + return bool(r and r[0].get("ok") == 1)
95 + except Exception: # noqa: BLE001
96 + return False
97 +
98 +
99 +async def migrate() -> list[str]:
100 + s = get_settings()
101 + await raw(f"CREATE DATABASE IF NOT EXISTS {s.ch_db}", database="default")
102 + await execute(
103 + "CREATE TABLE IF NOT EXISTS schema_migrations (name String, applied_at DateTime DEFAULT now()) "
104 + "ENGINE = MergeTree ORDER BY name"
105 + )
106 + done = {r["name"] for r in await query("SELECT name FROM schema_migrations")}
107 + applied: list[str] = []
108 + for f in sorted(MIGRATIONS_DIR.glob("*.sql")):
109 + if f.name in done:
110 + continue
111 + sql = f.read_text(encoding="utf-8")
112 + # one statement per block separated by ';\n' (comments allowed)
113 + for stmt in _split(sql):
114 + await execute(stmt)
115 + await insert("schema_migrations", [{"name": f.name}])
116 + applied.append(f.name)
117 + log.info("applied ch migration %s", f.name)
118 + return applied
119 +
120 +
121 +def _split(sql: str) -> list[str]:
122 + stmts: list[str] = []
123 + buf: list[str] = []
124 + for line in sql.splitlines():
125 + if line.strip().startswith("--"):
126 + continue
127 + buf.append(line)
128 + if line.rstrip().endswith(";"):
129 + stmt = "\n".join(buf).strip().rstrip(";").strip()
130 + if stmt:
131 + stmts.append(stmt)
132 + buf = []
133 + tail = "\n".join(buf).strip().rstrip(";").strip()
134 + if tail:
135 + stmts.append(tail)
136 + return stmts
added apps/api/src/internetpressure/db/migrations/ch/0001_init.sql +205 −0
@@ -0,0 +1,205 @@
1 +-- InternetPressure — ClickHouse analytical store. All timestamps UTC.
2 +
3 +-- Raw probe measurements (deduplicated on re-sent spooled batches by ReplacingMergeTree).
4 +CREATE TABLE IF NOT EXISTS measurements (
5 + ts DateTime64(3, 'UTC'),
6 + received_at DateTime64(3, 'UTC') DEFAULT now64(3),
7 + probe_id LowCardinality(String),
8 + target_id LowCardinality(String),
9 + kind LowCardinality(String),
10 + resolver LowCardinality(String) DEFAULT '',
11 + ok UInt8,
12 + error LowCardinality(String) DEFAULT '',
13 + dns_ms Nullable(Float32),
14 + tcp_ms Nullable(Float32),
15 + tls_ms Nullable(Float32),
16 + ttfb_ms Nullable(Float32),
17 + total_ms Nullable(Float32),
18 + http_status UInt16 DEFAULT 0,
19 + http_proto LowCardinality(String) DEFAULT '',
20 + tls_version LowCardinality(String) DEFAULT '',
21 + resolved_ip String DEFAULT '',
22 + dns_rcode LowCardinality(String) DEFAULT '',
23 + dns_answers Array(String) DEFAULT [],
24 + sent UInt8 DEFAULT 0,
25 + received UInt8 DEFAULT 0,
26 + packet_loss Nullable(Float32),
27 + rtt_min_ms Nullable(Float32),
28 + rtt_avg_ms Nullable(Float32),
29 + rtt_max_ms Nullable(Float32),
30 + jitter_ms Nullable(Float32)
31 +) ENGINE = ReplacingMergeTree
32 +PARTITION BY toYYYYMMDD(ts)
33 +ORDER BY (target_id, probe_id, kind, resolver, ts)
34 +TTL toDateTime(ts) + INTERVAL 180 DAY
35 +SETTINGS index_granularity = 8192;
36 +
37 +-- 1-minute downsample kept for a year (history / long-range charts); baselines read raw (180 d).
38 +CREATE TABLE IF NOT EXISTS measurements_1m (
39 + bucket DateTime('UTC'),
40 + probe_id LowCardinality(String),
41 + target_id LowCardinality(String),
42 + kind LowCardinality(String),
43 + resolver LowCardinality(String),
44 + n AggregateFunction(count, UInt8),
45 + ok_n AggregateFunction(sum, UInt8),
46 + ttfb AggregateFunction(quantileTDigest(0.5), Nullable(Float32)),
47 + tcp AggregateFunction(quantileTDigest(0.5), Nullable(Float32)),
48 + tls AggregateFunction(quantileTDigest(0.5), Nullable(Float32)),
49 + dns AggregateFunction(quantileTDigest(0.5), Nullable(Float32)),
50 + rtt AggregateFunction(quantileTDigest(0.5), Nullable(Float32)),
51 + loss AggregateFunction(avg, Nullable(Float32)),
52 + http5xx_n AggregateFunction(sum, UInt8),
53 + tlsfail_n AggregateFunction(sum, UInt8),
54 + reset_n AggregateFunction(sum, UInt8),
55 + dnsfail_n AggregateFunction(sum, UInt8)
56 +) ENGINE = AggregatingMergeTree
57 +PARTITION BY toYYYYMM(bucket)
58 +ORDER BY (target_id, probe_id, kind, resolver, bucket)
59 +TTL bucket + INTERVAL 1 YEAR;
60 +
61 +CREATE MATERIALIZED VIEW IF NOT EXISTS measurements_1m_mv TO measurements_1m AS
62 +SELECT
63 + toStartOfMinute(ts) AS bucket, probe_id, target_id, kind, resolver,
64 + countState(ok) AS n,
65 + sumState(ok) AS ok_n,
66 + quantileTDigestState(0.5)(ttfb_ms) AS ttfb,
67 + quantileTDigestState(0.5)(tcp_ms) AS tcp,
68 + quantileTDigestState(0.5)(tls_ms) AS tls,
69 + quantileTDigestState(0.5)(dns_ms) AS dns,
70 + quantileTDigestState(0.5)(rtt_avg_ms) AS rtt,
71 + avgState(packet_loss) AS loss,
72 + sumState(toUInt8(http_status >= 500)) AS http5xx_n,
73 + sumState(toUInt8(error IN ('tls_fail', 'tls_cert'))) AS tlsfail_n,
74 + sumState(toUInt8(error IN ('tcp_reset', 'reset', 'tcp_timeout', 'http_timeout'))) AS reset_n,
75 + sumState(toUInt8(kind = 'dns' AND ok = 0)) AS dnsfail_n
76 +FROM measurements
77 +GROUP BY bucket, probe_id, target_id, kind, resolver;
78 +
79 +-- Sampled traceroutes (route fingerprints).
80 +CREATE TABLE IF NOT EXISTS traceroutes (
81 + ts DateTime64(3, 'UTC'),
82 + probe_id LowCardinality(String),
83 + target_id LowCardinality(String),
84 + dest_ip String,
85 + reached UInt8,
86 + hop_count UInt8,
87 + total_ms Nullable(Float32),
88 + route_hash String,
89 + hop_ips Array(String),
90 + hop_rtts Array(Nullable(Float32)),
91 + asn_path Array(UInt32)
92 +) ENGINE = ReplacingMergeTree
93 +PARTITION BY toYYYYMM(ts)
94 +ORDER BY (probe_id, target_id, ts)
95 +TTL toDateTime(ts) + INTERVAL 1 YEAR;
96 +
97 +-- Probe health reports.
98 +CREATE TABLE IF NOT EXISTS probe_health (
99 + ts DateTime64(3, 'UTC'),
100 + probe_id LowCardinality(String),
101 + agent_version LowCardinality(String),
102 + uptime_s UInt64,
103 + buffered UInt32,
104 + spool_bytes UInt64,
105 + measurements_total UInt64,
106 + errors_total UInt64,
107 + clock_offset_ms Int32,
108 + rss_mb Float32,
109 + goroutines UInt16
110 +) ENGINE = MergeTree
111 +PARTITION BY toYYYYMM(ts)
112 +ORDER BY (probe_id, ts)
113 +TTL toDateTime(ts) + INTERVAL 180 DAY;
114 +
115 +-- BGP: raw events (short TTL), 10-second per-collector aggregates (forever), per-origin per-minute (30 d).
116 +CREATE TABLE IF NOT EXISTS bgp_events (
117 + ts DateTime64(3, 'UTC'),
118 + collector LowCardinality(String),
119 + peer_asn UInt32,
120 + prefix String,
121 + origin_asn UInt32,
122 + event_type LowCardinality(String), -- announcement | withdrawal
123 + path_len UInt8,
124 + as_path Array(UInt32)
125 +) ENGINE = MergeTree
126 +PARTITION BY toYYYYMMDD(ts)
127 +ORDER BY (collector, ts)
128 +TTL toDateTime(ts) + INTERVAL 3 DAY;
129 +
130 +CREATE TABLE IF NOT EXISTS bgp_stats_10s (
131 + ts DateTime('UTC'),
132 + collector LowCardinality(String),
133 + announcements UInt32,
134 + withdrawals UInt32,
135 + unique_prefixes UInt32,
136 + unique_origins UInt32,
137 + origin_changes UInt32,
138 + peers UInt16,
139 + avg_path_len Float32
140 +) ENGINE = SummingMergeTree((announcements, withdrawals, origin_changes))
141 +PARTITION BY toYYYYMM(ts)
142 +ORDER BY (collector, ts);
143 +
144 +CREATE TABLE IF NOT EXISTS bgp_origin_1m (
145 + ts DateTime('UTC'),
146 + origin_asn UInt32,
147 + announcements UInt32,
148 + withdrawals UInt32,
149 + prefixes UInt32
150 +) ENGINE = SummingMergeTree((announcements, withdrawals))
151 +PARTITION BY toYYYYMM(ts)
152 +ORDER BY (origin_asn, ts)
153 +TTL ts + INTERVAL 30 DAY;
154 +
155 +-- Pressure history (indefinite) — the moat.
156 +CREATE TABLE IF NOT EXISTS pressure_history (
157 + ts DateTime64(3, 'UTC'),
158 + scope_type LowCardinality(String), -- global | region | country | asn | service | component
159 + scope_id String,
160 + pressure Float32,
161 + confidence Float32,
162 + routing Nullable(Float32),
163 + latency Nullable(Float32),
164 + dns Nullable(Float32),
165 + availability Nullable(Float32),
166 + http_tls Nullable(Float32),
167 + path Nullable(Float32),
168 + corroboration Nullable(Float32),
169 + internal_status LowCardinality(String) DEFAULT 'ok'
170 +) ENGINE = MergeTree
171 +PARTITION BY toYYYYMM(ts)
172 +ORDER BY (scope_type, scope_id, ts);
173 +
174 +-- Provenance for explainability: every signal contribution per cycle.
175 +CREATE TABLE IF NOT EXISTS signal_features (
176 + ts DateTime64(3, 'UTC'),
177 + component LowCardinality(String),
178 + signal_id LowCardinality(String),
179 + scope_type LowCardinality(String),
180 + scope_id String,
181 + current Nullable(Float32),
182 + baseline Nullable(Float32),
183 + mad Nullable(Float32),
184 + robust_z Nullable(Float32),
185 + samples UInt32,
186 + stress Float32,
187 + contribution Float32
188 +) ENGINE = MergeTree
189 +PARTITION BY toYYYYMM(ts)
190 +ORDER BY (scope_type, scope_id, component, signal_id, ts)
191 +TTL toDateTime(ts) + INTERVAL 1 YEAR;
192 +
193 +-- Engine self-observability.
194 +CREATE TABLE IF NOT EXISTS engine_runs (
195 + ts DateTime64(3, 'UTC'),
196 + cycle_ms UInt32,
197 + internal_status LowCardinality(String),
198 + probes_fresh UInt16,
199 + probes_excluded UInt16,
200 + pairs UInt32,
201 + error String DEFAULT ''
202 +) ENGINE = MergeTree
203 +PARTITION BY toYYYYMM(ts)
204 +ORDER BY ts
205 +TTL toDateTime(ts) + INTERVAL 90 DAY;
added apps/api/src/internetpressure/db/migrations/pg/0001_init.sql +145 −0
@@ -0,0 +1,145 @@
1 +-- InternetPressure — registry, configuration, incidents (PostgreSQL 16+)
2 +
3 +CREATE TABLE IF NOT EXISTS probes (
4 + probe_id text PRIMARY KEY,
5 + name text NOT NULL,
6 + region text NOT NULL,
7 + country text,
8 + city text,
9 + provider text,
10 + asn integer,
11 + lat double precision,
12 + lon double precision,
13 + node text,
14 + key text NOT NULL, -- HMAC shared secret (hex); DB lives on the private network only
15 + enabled boolean NOT NULL DEFAULT true,
16 + version text,
17 + capabilities jsonb NOT NULL DEFAULT '[]',
18 + identity jsonb, -- best-effort public ip / asn / city reported by the agent
19 + last_seen_at timestamptz,
20 + last_ip inet,
21 + created_at timestamptz NOT NULL DEFAULT now(),
22 + updated_at timestamptz NOT NULL DEFAULT now()
23 +);
24 +
25 +CREATE TABLE IF NOT EXISTS services (
26 + slug text PRIMARY KEY,
27 + name text NOT NULL,
28 + category text,
29 + asns integer[] NOT NULL DEFAULT '{}',
30 + importance smallint NOT NULL DEFAULT 3,
31 + status jsonb, -- optional corroboration connector spec {kind, url}
32 + updated_at timestamptz NOT NULL DEFAULT now()
33 +);
34 +
35 +CREATE TABLE IF NOT EXISTS targets (
36 + target_id text PRIMARY KEY,
37 + name text NOT NULL,
38 + hostname text NOT NULL,
39 + url text,
40 + ip text,
41 + port integer NOT NULL DEFAULT 443,
42 + category text NOT NULL,
43 + provider text,
44 + service_id text REFERENCES services(slug) ON DELETE SET NULL,
45 + country text,
46 + region text NOT NULL DEFAULT 'global',
47 + importance smallint NOT NULL DEFAULT 3 CHECK (importance BETWEEN 1 AND 5),
48 + tier smallint NOT NULL DEFAULT 2 CHECK (tier BETWEEN 1 AND 3),
49 + checks jsonb NOT NULL DEFAULT '["http","dns","ping"]',
50 + traceroute boolean NOT NULL DEFAULT false,
51 + enabled boolean NOT NULL DEFAULT true,
52 + created_at timestamptz NOT NULL DEFAULT now(),
53 + updated_at timestamptz NOT NULL DEFAULT now()
54 +);
55 +CREATE INDEX IF NOT EXISTS targets_service_idx ON targets(service_id);
56 +CREATE INDEX IF NOT EXISTS targets_country_idx ON targets(country);
57 +
58 +CREATE TABLE IF NOT EXISTS asn_meta (
59 + asn integer PRIMARY KEY,
60 + name text,
61 + country text,
62 + importance smallint NOT NULL DEFAULT 2,
63 + updated_at timestamptz NOT NULL DEFAULT now()
64 +);
65 +
66 +CREATE TABLE IF NOT EXISTS config (
67 + key text PRIMARY KEY,
68 + value jsonb NOT NULL,
69 + updated_at timestamptz NOT NULL DEFAULT now(),
70 + updated_by text
71 +);
72 +
73 +CREATE TABLE IF NOT EXISTS events (
74 + event_id text PRIMARY KEY,
75 + slug text UNIQUE NOT NULL,
76 + type text NOT NULL,
77 + title text NOT NULL,
78 + summary text NOT NULL,
79 + status text NOT NULL, -- detected | developing | active | recovering | resolved
80 + scope_type text NOT NULL,
81 + scope_id text,
82 + scope_label text,
83 + started_at timestamptz NOT NULL,
84 + updated_at timestamptz NOT NULL,
85 + ended_at timestamptz,
86 + peak_pressure real NOT NULL DEFAULT 0,
87 + current_pressure real NOT NULL DEFAULT 0,
88 + confidence real NOT NULL DEFAULT 0,
89 + affected_probes integer NOT NULL DEFAULT 0,
90 + affected_targets integer NOT NULL DEFAULT 0,
91 + affected_asns integer[] NOT NULL DEFAULT '{}',
92 + affected_services text[] NOT NULL DEFAULT '{}',
93 + hypotheses jsonb NOT NULL DEFAULT '[]',
94 + evidence jsonb NOT NULL DEFAULT '[]',
95 + probes jsonb NOT NULL DEFAULT '[]',
96 + targets jsonb NOT NULL DEFAULT '[]',
97 + bgp jsonb,
98 + review text NOT NULL DEFAULT 'unreviewed',
99 + review_note text,
100 + below_since timestamptz, -- internal: continuous time under recover threshold
101 + cycles_above integer NOT NULL DEFAULT 0
102 +);
103 +CREATE INDEX IF NOT EXISTS events_status_idx ON events(status);
104 +CREATE INDEX IF NOT EXISTS events_started_idx ON events(started_at DESC);
105 +
106 +CREATE TABLE IF NOT EXISTS event_timeline (
107 + id bigserial PRIMARY KEY,
108 + event_id text NOT NULL REFERENCES events(event_id) ON DELETE CASCADE,
109 + ts timestamptz NOT NULL,
110 + status text NOT NULL,
111 + pressure real,
112 + note text
113 +);
114 +CREATE INDEX IF NOT EXISTS event_timeline_event_idx ON event_timeline(event_id, ts);
115 +
116 +CREATE TABLE IF NOT EXISTS annotations (
117 + id bigserial PRIMARY KEY,
118 + ts timestamptz NOT NULL,
119 + scope_type text NOT NULL,
120 + scope_id text,
121 + text text NOT NULL,
122 + author text NOT NULL DEFAULT 'admin',
123 + created_at timestamptz NOT NULL DEFAULT now()
124 +);
125 +
126 +CREATE TABLE IF NOT EXISTS vendor_status (
127 + service_slug text PRIMARY KEY REFERENCES services(slug) ON DELETE CASCADE,
128 + indicator text NOT NULL DEFAULT 'none', -- none | minor | major | critical | unknown
129 + incidents integer NOT NULL DEFAULT 0,
130 + titles jsonb NOT NULL DEFAULT '[]',
131 + source text,
132 + url text,
133 + raw jsonb,
134 + ok boolean NOT NULL DEFAULT true,
135 + error text,
136 + checked_at timestamptz NOT NULL DEFAULT now()
137 +);
138 +
139 +CREATE TABLE IF NOT EXISTS audit_log (
140 + id bigserial PRIMARY KEY,
141 + ts timestamptz NOT NULL DEFAULT now(),
142 + actor text NOT NULL,
143 + action text NOT NULL,
144 + detail jsonb
145 +);
added apps/api/src/internetpressure/db/pg.py +97 −0
@@ -0,0 +1,97 @@
1 +"""asyncpg pool + tiny migration runner (SQL files in db/migrations/pg)."""
2 +
3 +from __future__ import annotations
4 +
5 +import json
6 +import logging
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +import asyncpg
11 +
12 +from ..settings import get_settings
13 +
14 +log = logging.getLogger("ip.pg")
15 +MIGRATIONS_DIR = Path(__file__).parent / "migrations" / "pg"
16 +
17 +_pool: asyncpg.Pool | None = None
18 +
19 +
20 +def _enc(v: Any) -> str:
21 + return v if isinstance(v, str) else json.dumps(v)
22 +
23 +
24 +async def _init_conn(conn: asyncpg.Connection) -> None:
25 + # strings are passed through (callers may pre-serialise with util.dumps_str); everything else is json-encoded
26 + await conn.set_type_codec("jsonb", encoder=_enc, decoder=json.loads, schema="pg_catalog")
27 + await conn.set_type_codec("json", encoder=_enc, decoder=json.loads, schema="pg_catalog")
28 +
29 +
30 +async def pool() -> asyncpg.Pool:
31 + global _pool
32 + if _pool is None:
33 + _pool = await asyncpg.create_pool(
34 + get_settings().pg_dsn, min_size=1, max_size=8, init=_init_conn, command_timeout=30
35 + )
36 + return _pool
37 +
38 +
39 +async def close() -> None:
40 + global _pool
41 + if _pool is not None:
42 + await _pool.close()
43 + _pool = None
44 +
45 +
46 +async def fetch(sql: str, *args: Any) -> list[asyncpg.Record]:
47 + p = await pool()
48 + return await p.fetch(sql, *args)
49 +
50 +
51 +async def fetchrow(sql: str, *args: Any) -> asyncpg.Record | None:
52 + p = await pool()
53 + return await p.fetchrow(sql, *args)
54 +
55 +
56 +async def fetchval(sql: str, *args: Any) -> Any:
57 + p = await pool()
58 + return await p.fetchval(sql, *args)
59 +
60 +
61 +async def execute(sql: str, *args: Any) -> str:
62 + p = await pool()
63 + return await p.execute(sql, *args)
64 +
65 +
66 +async def executemany(sql: str, args: list[tuple]) -> None:
67 + p = await pool()
68 + async with p.acquire() as conn:
69 + await conn.executemany(sql, args)
70 +
71 +
72 +async def healthy() -> bool:
73 + try:
74 + return (await fetchval("SELECT 1")) == 1
75 + except Exception: # noqa: BLE001
76 + return False
77 +
78 +
79 +async def migrate() -> list[str]:
80 + """Apply pending SQL migrations in lexical order. Returns the list applied."""
81 + p = await pool()
82 + applied: list[str] = []
83 + async with p.acquire() as conn:
84 + await conn.execute(
85 + "CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz DEFAULT now())"
86 + )
87 + done = {r["name"] for r in await conn.fetch("SELECT name FROM schema_migrations")}
88 + for f in sorted(MIGRATIONS_DIR.glob("*.sql")):
89 + if f.name in done:
90 + continue
91 + sql = f.read_text(encoding="utf-8")
92 + async with conn.transaction():
93 + await conn.execute(sql)
94 + await conn.execute("INSERT INTO schema_migrations(name) VALUES ($1)", f.name)
95 + applied.append(f.name)
96 + log.info("applied pg migration %s", f.name)
97 + return applied
added apps/api/src/internetpressure/db/rds.py +87 −0
@@ -0,0 +1,87 @@
1 +"""Redis: live state, counters, pub/sub. Key layout (all prefixed `ip:`):
2 +
3 +ip:live:global JSON latest global pressure payload (GET /pressure/global)
4 +ip:live:regions JSON regions[] · ip:live:countries JSON · ip:live:asns · ip:live:services · ip:live:fronts
5 +ip:live:explain JSON deep explainability · ip:live:ticker JSON · ip:live:bgp JSON · ip:live:status JSON
6 +ip:live:incidents JSON active incidents[]
7 +ip:engine:last_run ISO · ip:engine:cycle_ms · ip:engine:internal_status
8 +ip:probe:<id>:seen ISO last batch · ip:probe:<id>:health JSON · ip:probe:<id>:version
9 +ip:ctr:meas:<minute> INT measurements accepted per UTC minute (TTL 2h) · ip:ctr:batch:<minute> · ip:ctr:rej:<minute>
10 +ip:bgp:rate JSON {ts, announcements_per_s, withdrawals_per_s, per_collector{...}} (TTL 60s)
11 +ip:latest:<target_id> HASH probe_id → JSON latest measurement(s)
12 +ip:boost JSON current sampling boost pushed to probes
13 +ip:config_version STR
14 +ip:replay:<probe>:<ts>:<hash> replay guard (TTL 10 min)
15 +channel ip:live pub/sub of SSE events {event, data}
16 +"""
17 +
18 +from __future__ import annotations
19 +
20 +import logging
21 +from typing import Any
22 +
23 +import redis.asyncio as aioredis
24 +
25 +from ..settings import get_settings
26 +from ..util import dumps, loads
27 +
28 +log = logging.getLogger("ip.redis")
29 +_r: aioredis.Redis | None = None
30 +CHANNEL = "ip:live"
31 +
32 +
33 +def r() -> aioredis.Redis:
34 + global _r
35 + if _r is None:
36 + _r = aioredis.from_url(get_settings().redis_url, decode_responses=False, socket_timeout=5, socket_connect_timeout=3)
37 + return _r
38 +
39 +
40 +async def close() -> None:
41 + global _r
42 + if _r is not None:
43 + await _r.aclose()
44 + _r = None
45 +
46 +
47 +async def healthy() -> bool:
48 + try:
49 + return bool(await r().ping())
50 + except Exception: # noqa: BLE001
51 + return False
52 +
53 +
54 +async def set_json(key: str, value: Any, ex: int | None = None) -> None:
55 + await r().set(key, dumps(value), ex=ex)
56 +
57 +
58 +async def get_json(key: str, default: Any = None) -> Any:
59 + raw = await r().get(key)
60 + if raw is None:
61 + return default
62 + try:
63 + return loads(raw)
64 + except Exception: # noqa: BLE001
65 + return default
66 +
67 +
68 +async def publish(event: str, data: Any) -> None:
69 + try:
70 + await r().publish(CHANNEL, dumps({"event": event, "data": data}))
71 + except Exception as exc: # noqa: BLE001
72 + log.warning("publish failed: %s", exc)
73 +
74 +
75 +async def incr_minute(kind: str, minute_key: str, n: int = 1, ttl: int = 7200) -> None:
76 + key = f"ip:ctr:{kind}:{minute_key}"
77 + pipe = r().pipeline()
78 + pipe.incrby(key, n)
79 + pipe.expire(key, ttl)
80 + await pipe.execute()
81 +
82 +
83 +async def sum_minutes(kind: str, minute_keys: list[str]) -> int:
84 + if not minute_keys:
85 + return 0
86 + vals = await r().mget([f"ip:ctr:{kind}:{m}" for m in minute_keys])
87 + return sum(int(v) for v in vals if v)
added apps/api/src/internetpressure/engine/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Pressure engine: baselines → robust anomalies → component/scope scores → global index → events → fronts."""
added apps/api/src/internetpressure/engine/compute.py +655 −0
@@ -0,0 +1,655 @@
1 +"""Turn current features + baselines into pair-level signals, then scope scores (global / region / country / ASN /
2 +service) with full provenance. Pure computation over already-fetched rows (no I/O) so it can be replayed/tested."""
3 +
4 +from __future__ import annotations
5 +
6 +from collections import defaultdict
7 +from dataclasses import dataclass, field
8 +from datetime import datetime
9 +from typing import Any
10 +
11 +from ..config import COMPONENT_IDS, COMPONENT_LABELS, PressureConfig
12 +from ..regions import RegionModel, country_name
13 +from . import scoring as S
14 +from .features import Baselines
15 +
16 +PROBE_COMPONENTS = ("latency", "dns", "availability", "http_tls", "path")
17 +
18 +
19 +@dataclass
20 +class Tags:
21 + probe_id: str
22 + target_id: str
23 + kind: str
24 + resolver: str
25 + src_region: str
26 + dst_region: str
27 + src_cc: str | None
28 + dst_cc: str | None
29 + asn: int | None
30 + service: str | None
31 + importance: int
32 +
33 +
34 +@dataclass
35 +class Sig:
36 + component: str
37 + signal_id: str
38 + pair: S.PairStress
39 + tags: Tags
40 +
41 +
42 +@dataclass
43 +class Ctx:
44 + cfg: PressureConfig
45 + regions: RegionModel
46 + probes: dict[str, dict[str, Any]]
47 + targets: dict[str, dict[str, Any]]
48 + services: dict[str, dict[str, Any]]
49 + baselines: Baselines
50 + now: datetime
51 + asn_of_ip: Any # callable ip → (asn, name)
52 + excluded_probes: set[str] = field(default_factory=set)
53 +
54 + @property
55 + def eng(self) -> dict[str, Any]:
56 + return self.cfg.engine
57 +
58 +
59 +@dataclass
60 +class ScopeScore:
61 + scope_type: str
62 + scope_id: str
63 + label: str
64 + components: dict[str, float | None]
65 + pressure: float
66 + contributions: dict[str, float]
67 + signals: dict[str, dict[str, dict[str, Any]]] # component → signal_id → {stress, weight, n, breadth, samples, current, baseline}
68 + probes: set[str]
69 + targets: set[str]
70 + confidence: float
71 + weight_total: float
72 +
73 +
74 +# ── helpers ────────────────────────────────────────────────────────────────────────────────────────────────────────
75 +
76 +def _cov(ctx: Ctx, samples: int) -> float:
77 + return S.coverage_factor(samples, int(ctx.eng.get("baseline_min_samples", 24)))
78 +
79 +
80 +def _z(ctx: Ctx, current: float | None, q: tuple[float | None, float | None, float | None], *, floor_abs: float = 1.0) -> tuple[float | None, float | None, float | None]:
81 + p25, p50, p75 = q
82 + mad = S.mad_from_iqr(p25, p75)
83 + z = S.robust_z(current, p50, mad, mad_floor_abs=floor_abs, mad_floor_rel=0.05,
84 + clip_low=float(ctx.eng.get("z_clip_low", -3)), clip_high=float(ctx.eng.get("z_clip_high", 8)))
85 + return z, p50, mad
86 +
87 +
88 +def _stress_z(ctx: Ctx, z: float | None) -> float:
89 + return S.z_to_stress(z, z0=float(ctx.eng.get("z_stress_start", 1.0)), z1=float(ctx.eng.get("z_stress_full", 6.0)))
90 +
91 +
92 +def _tags(ctx: Ctx, probe_id: str, target_id: str, kind: str, resolver: str, resolved_ip: str | None) -> Tags | None:
93 + p = ctx.probes.get(probe_id)
94 + t = ctx.targets.get(target_id)
95 + if not p or not t:
96 + return None
97 + asn = None
98 + if resolved_ip:
99 + asn, _ = ctx.asn_of_ip(resolved_ip)
100 + return Tags(
101 + probe_id=probe_id, target_id=target_id, kind=kind, resolver=resolver or "",
102 + src_region=p.get("region") or "global", dst_region=t.get("region") or "global",
103 + src_cc=p.get("country"), dst_cc=t.get("country"), asn=asn, service=t.get("service_id"),
104 + importance=int(t.get("importance") or 3),
105 + )
106 +
107 +
108 +# ── pair-level signals from probe measurements ─────────────────────────────────────────────────────────────────────
109 +
110 +def probe_signals(ctx: Ctx, cur_rows: list[dict[str, Any]], route_rows: list[dict[str, Any]]) -> list[Sig]:
111 + sigs: list[Sig] = []
112 + rate_scale = float(ctx.eng.get("rate_scale", 0.25))
113 + loss_scale = float(ctx.eng.get("loss_scale", 0.10))
114 + by_target_http: dict[str, list[tuple[str, float]]] = defaultdict(list) # target → [(probe, ok_ratio)]
115 + by_target_dns: dict[str, dict[str, bool]] = defaultdict(dict) # target → resolver → any ok (across probes)
116 +
117 + for r in cur_rows:
118 + if r["probe_id"] in ctx.excluded_probes:
119 + continue
120 + tags = _tags(ctx, r["probe_id"], r["target_id"], r["kind"], r.get("resolver") or "", r.get("resolved_ip"))
121 + if not tags:
122 + continue
123 + n = int(r["n"])
124 + if n <= 0:
125 + continue
126 + ok_ratio = float(r["ok_n"]) / n
127 + base = ctx.baselines.pairs.get((tags.probe_id, tags.target_id, tags.kind, tags.resolver))
128 + samples = base.samples if base else 0
129 + iw = ctx.cfg.importance_weight(tags.importance)
130 + w = iw * _cov(ctx, samples)
131 + key = f"{tags.probe_id}|{tags.target_id}|{tags.kind}|{tags.resolver}"
132 +
133 + def add(component: str, signal_id: str, stress: float, *, z: float | None = None, current: float | None = None,
134 + baseline: float | None = None, mad: float | None = None, weight: float | None = None, **meta: Any) -> None:
135 + sigs.append(Sig(component, signal_id, S.PairStress(key, stress, weight if weight is not None else w, z=z,
136 + current=current, baseline=baseline, mad=mad,
137 + samples=samples, meta=meta), tags))
138 +
139 + if tags.kind == "http":
140 + by_target_http[tags.target_id].append((tags.probe_id, ok_ratio))
141 + if base:
142 + for metric, sid in (("ttfb", "ttfb_z"), ("tcp", "tcp_z")):
143 + cur = r.get(metric)
144 + if cur is None:
145 + continue
146 + z, med, mad = _z(ctx, float(cur), base.q[metric])
147 + add("latency", sid, _stress_z(ctx, z), z=z, current=float(cur), baseline=med, mad=mad)
148 + fail = 1.0 - ok_ratio
149 + add("availability", "fail_rate_z", S.rate_stress(fail, base.fail_rate, scale=rate_scale * 2), current=fail,
150 + baseline=base.fail_rate)
151 + add("http_tls", "http_5xx_rate", S.rate_stress(float(r["n_5xx"]) / n, base.rate_5xx, scale=rate_scale),
152 + current=float(r["n_5xx"]) / n, baseline=base.rate_5xx)
153 + add("http_tls", "tls_fail_rate", S.rate_stress(float(r["n_tls"]) / n, base.rate_tls, scale=rate_scale),
154 + current=float(r["n_tls"]) / n, baseline=base.rate_tls)
155 + add("http_tls", "reset_timeout_rate", S.rate_stress(float(r["n_reset"]) / n, base.rate_reset, scale=rate_scale),
156 + current=float(r["n_reset"]) / n, baseline=base.rate_reset)
157 + else:
158 + # no baseline yet: only hard failures count, with importance weight but no coverage (weight 0 → excluded)
159 + pass
160 +
161 + elif tags.kind in ("ping", "tcp"):
162 + if base:
163 + cur = r.get("rtt")
164 + if cur is not None:
165 + z, med, mad = _z(ctx, float(cur), base.q["rtt"], floor_abs=0.5)
166 + add("latency", "rtt_z", _stress_z(ctx, z), z=z, current=float(cur), baseline=med, mad=mad)
167 + loss = r.get("loss")
168 + if loss is not None:
169 + add("latency", "loss", S.rate_stress(float(loss), base.loss, scale=loss_scale), current=float(loss),
170 + baseline=base.loss)
171 +
172 + elif tags.kind == "dns":
173 + by_target_dns[tags.target_id][tags.resolver] = by_target_dns[tags.target_id].get(tags.resolver, False) or ok_ratio > 0
174 + if base:
175 + fail = 1.0 - ok_ratio
176 + add("dns", "dns_fail_rate", S.rate_stress(fail, base.dns_fail, scale=rate_scale), current=fail,
177 + baseline=base.dns_fail)
178 + cur = r.get("dns")
179 + if cur is not None:
180 + z, med, mad = _z(ctx, float(cur), base.q["dns"], floor_abs=2.0)
181 + add("dns", "dns_latency_z", _stress_z(ctx, z), z=z, current=float(cur), baseline=med, mad=mad)
182 +
183 + # target-level: corroborated availability loss (≥2 probes in ≥2 regions failing the same target)
184 + amp = float(ctx.eng.get("availability_amplification", 8))
185 + for target_id, obs in by_target_http.items():
186 + t = ctx.targets.get(target_id)
187 + if not t:
188 + continue
189 + failing = [(p, ok) for p, ok in obs if ok < 0.5]
190 + regions_failing = {ctx.probes[p]["region"] for p, _ in failing if p in ctx.probes}
191 + corroborated = len(failing) >= 2 and len(regions_failing) >= 2
192 + # weighted mean over targets = amp·Σimp(down) / (amp·Σimp(down) + Σimp(up)) ≈ amp × share for small shares
193 + iw_t = ctx.cfg.importance_weight(int(t.get("importance") or 3))
194 + tags = Tags(probe_id="*", target_id=target_id, kind="http", resolver="", src_region="global",
195 + dst_region=t.get("region") or "global", src_cc=None, dst_cc=t.get("country"), asn=None,
196 + service=t.get("service_id"), importance=int(t.get("importance") or 3))
197 + sigs.append(Sig("availability", "target_down_corroborated",
198 + S.PairStress(f"*|{target_id}|down", 1.0 if corroborated else 0.0, iw_t * amp if corroborated else iw_t,
199 + current=float(len(failing)), baseline=0.0, samples=len(obs),
200 + meta={"probes_failing": [p for p, _ in failing], "regions": sorted(regions_failing)}),
201 + tags))
202 +
203 + # target-level: resolver disagreement (one resolver resolves, another fails — not answer-set differences)
204 + for target_id, res in by_target_dns.items():
205 + t = ctx.targets.get(target_id)
206 + if not t or len(res) < 2:
207 + continue
208 + oks = [v for v in res.values()]
209 + disagree = any(oks) and not all(oks)
210 + tags = Tags(probe_id="*", target_id=target_id, kind="dns", resolver="", src_region="global",
211 + dst_region=t.get("region") or "global", src_cc=None, dst_cc=t.get("country"), asn=None,
212 + service=t.get("service_id"), importance=int(t.get("importance") or 3))
213 + sigs.append(Sig("dns", "resolver_disagreement",
214 + S.PairStress(f"*|{target_id}|resolvers", 1.0 if disagree else 0.0,
215 + ctx.cfg.importance_weight(tags.importance), current=float(sum(1 for v in oks if not v)),
216 + baseline=0.0, samples=len(oks),
217 + meta={"failing_resolvers": sorted(k for k, v in res.items() if not v)}), tags))
218 +
219 + # path signals from traceroutes
220 + for r in route_rows:
221 + if r["probe_id"] in ctx.excluded_probes:
222 + continue
223 + tags = _tags(ctx, r["probe_id"], r["target_id"], "traceroute", "", None)
224 + if not tags:
225 + continue
226 + rb = ctx.baselines.routes.get((tags.probe_id, tags.target_id))
227 + if not rb or not rb.get("samples"):
228 + continue
229 + samples = int(rb["samples"])
230 + w = ctx.cfg.importance_weight(tags.importance) * S.coverage_factor(samples, 12)
231 + key = f"{tags.probe_id}|{tags.target_id}|route"
232 + changed = bool(rb.get("dominant")) and r["route_hash"] != rb["dominant"] and (rb.get("share") or 0) >= 0.5
233 + base_change = rb.get("change_rate") or 0.0
234 + # stress: a changed route on a normally stable pair (low change_rate) is strong; on a flappy pair it is weak
235 + stress = 0.0
236 + if changed:
237 + stress = S.rate_stress(1.0, base_change, scale=0.5, base_mult=2.0)
238 + sigs.append(Sig("path", "route_change_rate", S.PairStress(key, stress, w, current=1.0 if changed else 0.0,
239 + baseline=base_change, samples=samples,
240 + meta={"route_hash": r["route_hash"], "dominant": rb.get("dominant"),
241 + "share": rb.get("share"), "asn_path": r.get("asn_path"),
242 + "dom_asn_path": rb.get("dom_asn_path")}), tags))
243 + hc = r.get("hop_count")
244 + if hc is not None and rb.get("hop_q") and rb["hop_q"][1] is not None:
245 + z, med, mad = _z(ctx, float(hc), rb["hop_q"], floor_abs=1.0)
246 + sigs.append(Sig("path", "hop_count_z", S.PairStress(key, _stress_z(ctx, z), w, z=z, current=float(hc), baseline=med,
247 + mad=mad, samples=samples), tags))
248 + ms = r.get("total_ms")
249 + if changed and ms is not None and rb.get("ms_q") and rb["ms_q"][1] is not None:
250 + z, med, mad = _z(ctx, float(ms), rb["ms_q"], floor_abs=2.0)
251 + sigs.append(Sig("path", "path_latency_shift", S.PairStress(key, _stress_z(ctx, z), w, z=z, current=float(ms),
252 + baseline=med, mad=mad, samples=samples), tags))
253 + return sigs
254 +
255 +
256 +# ── routing (BGP) signals: global level ────────────────────────────────────────────────────────────────────────────
257 +
258 +@dataclass
259 +class RoutingResult:
260 + stresses: dict[str, float]
261 + details: dict[str, dict[str, Any]]
262 + collectors: list[dict[str, Any]]
263 + fresh: bool
264 + updates_per_s: float
265 + announcements_per_s: float
266 + withdrawals_per_s: float
267 + baseline_a_per_s: float | None
268 + baseline_w_per_s: float | None
269 +
270 +
271 +def routing_signals(ctx: Ctx, bgp_rows: list[dict[str, Any]], window_s: int, *, fresh: bool) -> RoutingResult:
272 + cols: list[dict[str, Any]] = []
273 + z_w: list[tuple[float, float]] = [] # (z, weight=peers)
274 + z_a: list[tuple[float, float]] = []
275 + z_o: list[tuple[float, float]] = []
276 + tot_a = tot_w = 0.0
277 + base_a = base_w = 0.0
278 + base_any = False
279 + minutes = window_s / 60.0
280 + for r in bgp_rows:
281 + c = r["collector"]
282 + b = ctx.baselines.bgp.get(c)
283 + a_pm = float(r["a"]) / minutes
284 + w_pm = float(r["w"]) / minutes
285 + o_pm = float(r["o"]) / minutes
286 + peers = float(r.get("peers") or 1)
287 + tot_a += float(r["a"])
288 + tot_w += float(r["w"])
289 + entry: dict[str, Any] = {"id": c, "announcements_per_s": round(a_pm / 60, 2), "withdrawals_per_s": round(w_pm / 60, 2),
290 + "peers": int(peers), "last_message": r.get("last_ts"), "fresh": True, "z_w": None, "z_a": None}
291 + if b and b["samples"] >= 30:
292 + base_any = True
293 + za, _, _ = _z(ctx, a_pm, b["a_q"], floor_abs=5.0)
294 + zw, _, _ = _z(ctx, w_pm, b["w_q"], floor_abs=2.0)
295 + zo, _, _ = _z(ctx, o_pm, b["o_q"], floor_abs=1.0)
296 + base_a += (b["a_q"][1] or 0.0)
297 + base_w += (b["w_q"][1] or 0.0)
298 + entry.update({"z_w": zw, "z_a": za, "baseline_a_per_s": round((b["a_q"][1] or 0) / 60, 2),
299 + "baseline_w_per_s": round((b["w_q"][1] or 0) / 60, 2)})
300 + if za is not None:
301 + z_a.append((za, peers))
302 + if zw is not None:
303 + z_w.append((zw, peers))
304 + if zo is not None:
305 + z_o.append((zo, peers))
306 + cols.append(entry)
307 +
308 + def wmedian(zs: list[tuple[float, float]]) -> float | None:
309 + if not zs:
310 + return None
311 + zs = sorted(zs)
312 + tw = sum(w for _, w in zs)
313 + acc = 0.0
314 + for z, w in zs:
315 + acc += w
316 + if acc >= tw / 2:
317 + return z
318 + return zs[-1][0]
319 +
320 + stresses: dict[str, float] = {}
321 + details: dict[str, dict[str, Any]] = {}
322 + if fresh and base_any:
323 + mw, ma, mo = wmedian(z_w), wmedian(z_a), wmedian(z_o)
324 + stresses["bgp_withdrawals_z"] = _stress_z(ctx, mw)
325 + stresses["bgp_announcements_z"] = _stress_z(ctx, ma)
326 + stresses["bgp_origin_changes_z"] = _stress_z(ctx, mo)
327 + abnormal = sum(1 for z, _ in z_w + z_a if z is not None and z >= float(ctx.eng.get("z_anomaly", 3)))
328 + total = max(1, len(z_w) + len(z_a))
329 + share = abnormal / total
330 + # a minority of collectors seeing instability = possible regional routing issue
331 + stresses["bgp_collector_disagreement"] = min(1.0, share * 2) if 0 < share < 0.5 else 0.0
332 + details = {
333 + "bgp_withdrawals_z": {"z": mw, "current": round(tot_w / window_s, 2), "baseline": round(base_w / 60, 2), "samples": len(z_w)},
334 + "bgp_announcements_z": {"z": ma, "current": round(tot_a / window_s, 2), "baseline": round(base_a / 60, 2), "samples": len(z_a)},
335 + "bgp_origin_changes_z": {"z": mo, "samples": len(z_o)},
336 + "bgp_collector_disagreement": {"current": share, "samples": total},
337 + }
338 + return RoutingResult(stresses, details, cols, fresh, (tot_a + tot_w) / window_s, tot_a / window_s, tot_w / window_s,
339 + (base_a / 60) if base_any else None, (base_w / 60) if base_any else None)
340 +
341 +
342 +def asn_routing_stress(ctx: Ctx, asn: int, cur: dict[str, float] | None) -> tuple[float | None, dict[str, Any]]:
343 + b = ctx.baselines.bgp_origin.get(asn)
344 + if not b or not cur:
345 + return None, {}
346 + za, _, _ = _z(ctx, cur["a"], b["a_q"], floor_abs=2.0)
347 + zw, _, _ = _z(ctx, cur["w"], b["w_q"], floor_abs=1.0)
348 + stress = 0.6 * _stress_z(ctx, zw) + 0.4 * _stress_z(ctx, za)
349 + return stress, {"z_w": zw, "z_a": za, "announcements_per_min": cur["a"], "withdrawals_per_min": cur["w"],
350 + "baseline_a": b["a_q"][1], "baseline_w": b["w_q"][1]}
351 +
352 +
353 +# ── corroboration ──────────────────────────────────────────────────────────────────────────────────────────────────
354 +
355 +IMPACT = {"none": 0.0, "minor": 0.25, "major": 0.6, "critical": 1.0}
356 +
357 +
358 +def corroboration_stress(vendor_rows: list[dict[str, Any]], services: dict[str, dict[str, Any]]) -> tuple[float, list[dict[str, Any]]]:
359 + total = 0.0
360 + active: list[dict[str, Any]] = []
361 + for v in vendor_rows:
362 + if not v.get("ok"):
363 + continue
364 + imp = IMPACT.get(v.get("indicator") or "none", 0.0)
365 + if imp <= 0:
366 + continue
367 + svc = services.get(v["service_slug"]) or {}
368 + w = {1: 0.4, 2: 0.7, 3: 1.0, 4: 1.5, 5: 2.2}.get(int(svc.get("importance") or 3), 1.0)
369 + total += imp * w
370 + active.append({"slug": v["service_slug"], "indicator": v["indicator"], "incidents": v.get("incidents"), "weight": w})
371 + return min(1.0, total / 4.0), active
372 +
373 +
374 +# ── scope aggregation ──────────────────────────────────────────────────────────────────────────────────────────────
375 +
376 +def bucketize(sigs: list[Sig]) -> dict[tuple[str, str], list[Sig]]:
377 + b: dict[tuple[str, str], list[Sig]] = defaultdict(list)
378 + for s in sigs:
379 + b[("global", "global")].append(s)
380 + t = s.tags
381 + regs = {t.src_region, t.dst_region} - {"global", None}
382 + for r in regs:
383 + b[("region", r)].append(s)
384 + for cc in {t.src_cc, t.dst_cc} - {None}:
385 + b[("country", cc)].append(s)
386 + if t.asn:
387 + b[("asn", str(t.asn))].append(s)
388 + if t.service:
389 + b[("service", t.service)].append(s)
390 + return b
391 +
392 +
393 +def score_scope(ctx: Ctx, scope_type: str, scope_id: str, label: str, sigs: list[Sig], *,
394 + routing: dict[str, float] | None, routing_details: dict[str, dict[str, Any]] | None,
395 + corroboration: float | None) -> ScopeScore:
396 + per: dict[str, dict[str, list[S.PairStress]]] = defaultdict(lambda: defaultdict(list))
397 + probes: set[str] = set()
398 + targets: set[str] = set()
399 + for s in sigs:
400 + per[s.component][s.signal_id].append(s.pair)
401 + if s.tags.probe_id != "*":
402 + probes.add(s.tags.probe_id)
403 + targets.add(s.tags.target_id)
404 +
405 + k = float(ctx.eng.get("saturation_k", 1.2))
406 + components: dict[str, float | None] = {}
407 + signals_out: dict[str, dict[str, dict[str, Any]]] = {}
408 + total_weight = 0.0
409 + for comp in PROBE_COMPONENTS:
410 + sw = ctx.cfg.signal_weights(comp)
411 + stresses: dict[str, float] = {}
412 + out: dict[str, dict[str, Any]] = {}
413 + for sid, pairs in per.get(comp, {}).items():
414 + st, tw, n = S.weighted_stress(pairs)
415 + if n == 0:
416 + continue
417 + stresses[sid] = st
418 + total_weight += tw
419 + worst = max(pairs, key=lambda p: p.stress * p.weight, default=None)
420 + out[sid] = {
421 + "stress": round(st, 4), "weight": round(tw, 3), "n": n, "breadth": round(S.breadth(pairs), 4),
422 + "samples": sum(p.samples for p in pairs) // max(1, n),
423 + "current": worst.current if worst else None, "baseline": worst.baseline if worst else None,
424 + "z": worst.z if worst else None, "mad": worst.mad if worst else None, "sw": sw.get(sid, 0.0),
425 + }
426 + if stresses:
427 + components[comp] = round(S.component_score(stresses, sw, k=k), 2)
428 + for sid in out:
429 + out[sid]["contribution"] = round(components[comp] * (sw.get(sid, 0) * stresses[sid]) /
430 + max(1e-9, sum(sw.get(s2, 0) * stresses[s2] for s2 in stresses)), 2) \
431 + if components[comp] else 0.0
432 + else:
433 + components[comp] = None
434 + signals_out[comp] = out
435 +
436 + if routing is not None and routing:
437 + sw = ctx.cfg.signal_weights("routing")
438 + components["routing"] = round(S.component_score(routing, sw, k=k), 2)
439 + signals_out["routing"] = {
440 + sid: {"stress": round(st, 4), "weight": sw.get(sid, 0), "n": 1, "breadth": None,
441 + **(routing_details or {}).get(sid, {}), "sw": sw.get(sid, 0),
442 + "contribution": round(components["routing"] * (sw.get(sid, 0) * st) /
443 + max(1e-9, sum(sw.get(s2, 0) * routing[s2] for s2 in routing)), 2) if components["routing"] else 0.0}
444 + for sid, st in routing.items()
445 + }
446 + else:
447 + components["routing"] = None
448 + if corroboration is not None:
449 + components["corroboration"] = round(S.saturate(corroboration, k), 2)
450 + signals_out["corroboration"] = {"vendor_incidents": {"stress": round(corroboration, 4), "weight": 1.0, "n": 1,
451 + "contribution": components["corroboration"], "sw": 1.0}}
452 + else:
453 + components["corroboration"] = None
454 +
455 + pressure, contributions = S.global_score(components, ctx.cfg.weights)
456 + regions_here = {ctx.probes[p]["region"] for p in probes if p in ctx.probes}
457 + agreeing = sum(1 for c, v in components.items() if v is not None and v >= 25)
458 + samples = max((sig["samples"] for comp in signals_out.values() for sig in comp.values() if sig.get("samples")), default=0)
459 + conf = S.confidence_score(probes=len(probes), probe_regions=len(regions_here), signals_agreeing=agreeing,
460 + samples=samples, min_samples=int(ctx.eng.get("baseline_min_samples", 24)),
461 + bgp_corroborated=bool(components.get("routing") and components["routing"] >= 40),
462 + external_corroborated=bool(components.get("corroboration") and components["corroboration"] > 0))
463 + return ScopeScore(scope_type, scope_id, label, components, round(pressure, 2),
464 + {c: round(v, 2) for c, v in contributions.items()}, signals_out, probes, targets, conf, total_weight)
465 +
466 +
467 +# ── explainability ─────────────────────────────────────────────────────────────────────────────────────────────────
468 +
469 +def explain_global(ctx: Ctx, g: ScopeScore, sigs: list[Sig], routing: RoutingResult | None,
470 + corroboration_active: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, list[dict[str, Any]]]]:
471 + """Returns (explain rows, drivers per component). Attribution = component contribution × share of stress mass."""
472 + explain: list[dict[str, Any]] = []
473 + drivers: dict[str, list[dict[str, Any]]] = {c: [] for c in COMPONENT_IDS}
474 + region_name = lambda rid: (ctx.regions.get(rid).name if ctx.regions.get(rid) else rid) # noqa: E731
475 +
476 + for comp in PROBE_COMPONENTS:
477 + P = g.contributions.get(comp, 0.0)
478 + if P <= 0.05:
479 + continue
480 + sw = ctx.cfg.signal_weights(comp)
481 + mass_by_region: dict[str, float] = defaultdict(float)
482 + weight_by_region: dict[str, float] = defaultdict(float)
483 + mass_by_signal: dict[str, float] = defaultdict(float)
484 + mass_by_service: dict[str, float] = defaultdict(float)
485 + total = 0.0
486 + for s in sigs:
487 + if s.component != comp:
488 + continue
489 + m = s.pair.stress * s.pair.weight * sw.get(s.signal_id, 0)
490 + reg = s.tags.dst_region if s.tags.dst_region != "global" else s.tags.src_region
491 + mass_by_region[reg] += m
492 + weight_by_region[reg] += s.pair.weight * sw.get(s.signal_id, 0)
493 + mass_by_signal[s.signal_id] += m
494 + if s.tags.service:
495 + mass_by_service[s.tags.service] += m
496 + total += m
497 + if total <= 0:
498 + continue
499 + for sid, m in sorted(mass_by_signal.items(), key=lambda kv: -kv[1])[:3]:
500 + drivers[comp].append({"label": ctx.cfg.signal_label(comp, sid), "points": round(P * m / total, 1),
501 + "scope_type": "signal", "scope_id": sid})
502 + top_regions = sorted(mass_by_region.items(), key=lambda kv: -kv[1])[:2]
503 + top_sid = max(mass_by_signal.items(), key=lambda kv: kv[1])[0]
504 + for reg, m in top_regions:
505 + pts = P * m / total
506 + if pts >= 0.5:
507 + explain.append({"text": f"+{pts:.1f} from {region_name(reg)} {_signal_phrase(top_sid)}", "points": round(pts, 1),
508 + "component": comp, "scope_type": "region", "scope_id": reg})
509 + top_svc = sorted(mass_by_service.items(), key=lambda kv: -kv[1])[:1]
510 + for svc, m in top_svc:
511 + pts = P * m / total
512 + if pts >= 1.0 and comp in ("availability", "http_tls"):
513 + name = (ctx.services.get(svc) or {}).get("name", svc)
514 + explain.append({"text": f"+{pts:.1f} from {name}-related endpoint degradation", "points": round(pts, 1),
515 + "component": comp, "scope_type": "service", "scope_id": svc})
516 + # negative: a well-covered region that stays calm dilutes the component
517 + tw_all = sum(weight_by_region.values())
518 + for reg, wr in weight_by_region.items():
519 + if wr / tw_all >= 0.15 and mass_by_region[reg] / max(1e-9, wr) < 0.1 * (total / tw_all):
520 + pts = -P * (wr / tw_all)
521 + if pts <= -0.5:
522 + explain.append({"text": f"{pts:.1f} because {region_name(reg)} remains stable", "points": round(pts, 1),
523 + "component": comp, "scope_type": "region", "scope_id": reg})
524 + break
525 +
526 + if routing and g.contributions.get("routing", 0) > 0.05:
527 + P = g.contributions["routing"]
528 + sw = ctx.cfg.signal_weights("routing")
529 + mass = {sid: st * sw.get(sid, 0) for sid, st in routing.stresses.items()}
530 + total = sum(mass.values()) or 1.0
531 + for sid, m in sorted(mass.items(), key=lambda kv: -kv[1])[:3]:
532 + if m > 0:
533 + drivers["routing"].append({"label": ctx.cfg.signal_label("routing", sid), "points": round(P * m / total, 1),
534 + "scope_type": "bgp", "scope_id": sid.replace("bgp_", "").replace("_z", "")})
535 + ratio = None
536 + if routing.baseline_w_per_s:
537 + ratio = routing.withdrawals_per_s / routing.baseline_w_per_s
538 + txt = f"+{P:.1f} points from elevated BGP route churn" + (f" (withdrawals {ratio:.1f}× baseline)" if ratio and ratio > 1.5 else "")
539 + explain.append({"text": txt, "points": round(P, 1), "component": "routing", "scope_type": "global", "scope_id": None})
540 +
541 + if g.contributions.get("corroboration", 0) > 0.05 and corroboration_active:
542 + P = g.contributions["corroboration"]
543 + names = ", ".join((ctx.services.get(a["slug"]) or {}).get("name", a["slug"]) for a in corroboration_active[:3])
544 + explain.append({"text": f"+{P:.1f} from incidents declared by {names}", "points": round(P, 1),
545 + "component": "corroboration", "scope_type": "global", "scope_id": None})
546 + drivers["corroboration"].append({"label": f"Declared incidents: {names}", "points": round(P, 1),
547 + "scope_type": "vendor", "scope_id": None})
548 +
549 + explain.sort(key=lambda e: -abs(e["points"]))
550 + return explain[:8], drivers
551 +
552 +
553 +def _signal_phrase(sid: str) -> str:
554 + return {
555 + "ttfb_z": "HTTP latency", "tcp_z": "TCP connect latency", "rtt_z": "round-trip latency", "loss": "packet loss",
556 + "dns_fail_rate": "DNS failures", "dns_latency_z": "DNS latency", "resolver_disagreement": "resolver disagreement",
557 + "target_down_corroborated": "endpoint unavailability", "fail_rate_z": "failure rate", "http_5xx_rate": "HTTP 5xx errors",
558 + "tls_fail_rate": "TLS failures", "reset_timeout_rate": "connection resets", "route_change_rate": "route changes",
559 + "hop_count_z": "path length changes", "path_latency_shift": "latency on rerouted paths",
560 + }.get(sid, sid)
561 +
562 +
563 +def component_rows(ctx: Ctx, g: ScopeScore, drivers: dict[str, list[dict[str, Any]]], prev_components: dict[str, float | None]) -> list[dict[str, Any]]:
564 + rows = []
565 + for c in COMPONENT_IDS:
566 + score = g.components.get(c)
567 + prev = prev_components.get(c) if prev_components else None
568 + delta = (score - prev) if (score is not None and prev is not None) else None
569 + rows.append({
570 + "id": c, "label": COMPONENT_LABELS[c], "score": score, "weight": ctx.cfg.weights.get(c),
571 + "contribution": g.contributions.get(c, 0.0) if score is not None else None,
572 + "trend": S.trend_word(delta), "delta_1h": round(delta, 1) if delta is not None else None,
573 + "confidence": g.confidence if score is not None else None, "drivers": drivers.get(c, [])[:3],
574 + })
575 + return rows
576 +
577 +
578 +def label_for(ctx: Ctx, scope_type: str, scope_id: str) -> str:
579 + if scope_type == "global":
580 + return "Global"
581 + if scope_type == "region":
582 + r = ctx.regions.get(scope_id)
583 + return r.name if r else scope_id
584 + if scope_type == "country":
585 + return country_name(scope_id)
586 + if scope_type == "service":
587 + return (ctx.services.get(scope_id) or {}).get("name", scope_id)
588 + if scope_type == "asn":
589 + return f"AS{scope_id}"
590 + return scope_id
591 +
592 +
593 +# ── latency matrix (probe region → target region) for /latency and the ticker ──────────────────────────────────────
594 +
595 +def latency_matrix(ctx: Ctx, sigs: list[Sig], cur_rows: list[dict[str, Any]]) -> dict[str, Any]:
596 + from statistics import median
597 +
598 + cells: dict[tuple[str, str], dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
599 + by_probe: dict[str, dict[str, list[float]]] = defaultdict(lambda: defaultdict(list))
600 + g_rtt: list[float] = []
601 + g_rtt_b: list[float] = []
602 + g_ttfb: list[float] = []
603 + g_ttfb_b: list[float] = []
604 + g_loss: list[float] = []
605 + for s in sigs:
606 + if s.tags.probe_id == "*" or s.component != "latency":
607 + continue
608 + c = cells[(s.tags.src_region, s.tags.dst_region)]
609 + bp = by_probe[s.tags.probe_id]
610 + if s.signal_id == "rtt_z" and s.pair.current is not None:
611 + c["rtt"].append(s.pair.current)
612 + bp["rtt"].append(s.pair.current)
613 + g_rtt.append(s.pair.current)
614 + if s.pair.baseline is not None:
615 + c["rtt_b"].append(s.pair.baseline)
616 + g_rtt_b.append(s.pair.baseline)
617 + if s.pair.z is not None:
618 + c["z"].append(s.pair.z)
619 + bp["z"].append(s.pair.z)
620 + elif s.signal_id == "ttfb_z" and s.pair.current is not None:
621 + c["ttfb"].append(s.pair.current)
622 + bp["ttfb"].append(s.pair.current)
623 + g_ttfb.append(s.pair.current)
624 + if s.pair.baseline is not None:
625 + g_ttfb_b.append(s.pair.baseline)
626 + if s.pair.z is not None:
627 + c["z"].append(s.pair.z)
628 + bp["z"].append(s.pair.z)
629 + elif s.signal_id == "loss" and s.pair.current is not None:
630 + c["loss"].append(s.pair.current)
631 + bp["loss"].append(s.pair.current)
632 + g_loss.append(s.pair.current)
633 + # raw window medians (available even before baselines exist)
634 + raw_rtt = [float(r["rtt"]) for r in cur_rows if r["kind"] in ("ping", "tcp") and r.get("rtt") is not None]
635 + raw_ttfb = [float(r["ttfb"]) for r in cur_rows if r["kind"] == "http" and r.get("ttfb") is not None]
636 + raw_loss = [float(r["loss"]) for r in cur_rows if r["kind"] in ("ping", "tcp") and r.get("loss") is not None]
637 +
638 + def med(v: list[float]) -> float | None:
639 + return round(median(v), 1) if v else None
640 +
641 + matrix = []
642 + for (src, dst), c in sorted(cells.items()):
643 + if not c["rtt"] and not c["ttfb"]:
644 + continue
645 + matrix.append({"from": src, "to": dst, "rtt_ms": med(c["rtt"]), "rtt_ms_baseline": med(c["rtt_b"]), "ttfb_ms": med(c["ttfb"]),
646 + "loss_pct": round(100 * sum(c["loss"]) / len(c["loss"]), 2) if c["loss"] else None,
647 + "z": round(median(c["z"]), 2) if c["z"] else None, "pairs": len(c["rtt"]) + len(c["ttfb"])})
648 + probes_out = [{"probe_id": pid, "rtt_ms_median": med(v["rtt"]), "ttfb_ms_median": med(v["ttfb"]),
649 + "loss_pct": round(100 * sum(v["loss"]) / len(v["loss"]), 2) if v["loss"] else None,
650 + "z": round(median(v["z"]), 2) if v["z"] else None} for pid, v in sorted(by_probe.items())]
651 + return {
652 + "global": {"rtt_ms_median": med(raw_rtt), "rtt_ms_baseline": med(g_rtt_b), "ttfb_ms_median": med(raw_ttfb),
653 + "ttfb_ms_baseline": med(g_ttfb_b), "packet_loss_pct": round(100 * sum(raw_loss) / len(raw_loss), 2) if raw_loss else None},
654 + "matrix": matrix, "by_probe": probes_out,
655 + }
added apps/api/src/internetpressure/engine/events.py +266 −0
@@ -0,0 +1,266 @@
1 +"""Event engine: rule-based state machine over scope scores (Postgres-backed), with causal hypotheses.
2 +
3 +detected → developing → active → recovering → resolved. One open event per (scope_type, scope_id)."""
4 +
5 +from __future__ import annotations
6 +
7 +import logging
8 +from datetime import datetime
9 +from typing import Any
10 +
11 +from slugify import slugify
12 +from ulid import ULID
13 +
14 +from ..config import COMPONENT_LABELS
15 +from ..db import pg, rds
16 +from ..util import dumps_str, iso, utcnow
17 +from .compute import Ctx, ScopeScore
18 +
19 +log = logging.getLogger("ip.events")
20 +
21 +EVENT_SCOPES = ("global", "region", "service", "asn", "country")
22 +
23 +
24 +def _dominant(components: dict[str, float | None]) -> str:
25 + best = None
26 + for c, v in components.items():
27 + if v is None:
28 + continue
29 + if best is None or v > components[best]: # type: ignore[operator]
30 + best = c
31 + return best or "latency"
32 +
33 +
34 +def _event_type(scope_type: str, dominant: str) -> str:
35 + if scope_type == "global":
36 + return "global_pressure"
37 + return {
38 + "latency": "regional_latency", "dns": "dns_disruption", "routing": "routing_instability",
39 + "availability": "availability_loss" if scope_type != "service" else "service_degradation",
40 + "http_tls": "service_degradation", "path": "path_instability", "corroboration": "service_degradation",
41 + }.get(dominant, "regional_latency")
42 +
43 +
44 +def _title(ctx: Ctx, sc: ScopeScore, dominant: str) -> str:
45 + what = {
46 + "latency": "latency anomaly", "dns": "DNS disruption", "routing": "routing instability",
47 + "availability": "availability loss", "http_tls": "HTTP/TLS degradation", "path": "path instability",
48 + "corroboration": "declared incidents",
49 + }.get(dominant, "pressure anomaly")
50 + if sc.scope_type == "global":
51 + return "Global Internet pressure elevated"
52 + return f"{sc.label} {what}"
53 +
54 +
55 +def _summary(ctx: Ctx, sc: ScopeScore, dominant: str) -> str:
56 + n_probes = len(sc.probes)
57 + n_targets = len(sc.targets)
58 + comp = COMPONENT_LABELS.get(dominant, dominant).lower()
59 + elevated = [COMPONENT_LABELS[c].lower() for c, v in sc.components.items() if v is not None and v >= 40 and c != dominant]
60 + extra = f" alongside {', '.join(elevated[:2])}" if elevated else ""
61 + where = "" if sc.scope_type == "global" else f" for {sc.label}"
62 + return (f"Elevated {comp} pressure observed{where} from {n_probes} probe{'s' if n_probes != 1 else ''} "
63 + f"toward {n_targets} target{'s' if n_targets != 1 else ''}{extra}.")
64 +
65 +
66 +def hypotheses(ctx: Ctx, sc: ScopeScore, routing_ok: bool) -> list[dict[str, Any]]:
67 + c = sc.components
68 + sig = sc.signals
69 + out: list[dict[str, Any]] = []
70 +
71 + def ev(component: str, sid: str, fmt: str) -> str | None:
72 + s = sig.get(component, {}).get(sid)
73 + if not s or (s.get("stress") or 0) < 0.15:
74 + return None
75 + return fmt.format(**{k: (round(v, 1) if isinstance(v, float) else v) for k, v in s.items()})
76 +
77 + lat, path, dns, avail, http, rout = (c.get(k) or 0 for k in ("latency", "path", "dns", "availability", "http_tls", "routing"))
78 + if path >= 35 and lat >= 30:
79 + e = [x for x in (ev("path", "route_change_rate", "Route fingerprints changed on {breadth:.0%} of monitored paths"),
80 + ev("latency", "ttfb_z", "HTTP latency at robust z {z} vs baseline")) if x]
81 + out.append({"text": "Possible upstream transit issue or rerouting", "confidence": round(min(0.85, 0.4 + path / 200 + lat / 300), 2), "evidence": e})
82 + if dns >= 40:
83 + e = [x for x in (ev("dns", "dns_fail_rate", "DNS failure rate {current:.0%} (baseline {baseline:.1%})"),
84 + ev("dns", "resolver_disagreement", "Resolvers disagree on {n} target(s)")) if x]
85 + if sc.scope_type == "service":
86 + out.append({"text": "Likely DNS provider or authoritative zone disruption", "confidence": round(min(0.85, 0.45 + dns / 250), 2), "evidence": e})
87 + else:
88 + out.append({"text": "Possible resolver-side or authoritative DNS disruption", "confidence": round(min(0.8, 0.35 + dns / 250), 2), "evidence": e})
89 + if rout >= 40 and routing_ok:
90 + e = [x for x in (ev("routing", "bgp_withdrawals_z", "BGP withdrawals at robust z {z} ({current} /s vs {baseline} /s baseline)"),
91 + ev("routing", "bgp_origin_changes_z", "Origin ASN changes at robust z {z}")) if x]
92 + if sig.get("routing", {}).get("bgp_origin_changes_z", {}).get("stress", 0) >= 0.4:
93 + out.append({"text": "Possible route leak or hijack (origin changes)", "confidence": round(min(0.7, 0.3 + rout / 250), 2), "evidence": e})
94 + else:
95 + out.append({"text": "Likely large-scale route withdrawal / reconvergence", "confidence": round(min(0.85, 0.4 + rout / 200), 2), "evidence": e})
96 + if sc.scope_type == "service" and (avail >= 35 or http >= 35):
97 + e = [x for x in (ev("availability", "target_down_corroborated", "{current:.0f} probe(s) fail the same endpoints from several regions"),
98 + ev("http_tls", "http_5xx_rate", "HTTP 5xx rate {current:.0%}"),
99 + ev("http_tls", "tls_fail_rate", "TLS failures {current:.0%}")) if x]
100 + out.append({"text": f"Probable service-side degradation at {sc.label}", "confidence": round(min(0.9, 0.5 + max(avail, http) / 200), 2), "evidence": e})
101 + if sc.scope_type in ("region", "country") and avail >= 35 and lat < 30 and path < 30:
102 + out.append({"text": "Possible regional connectivity issue toward specific destinations", "confidence": 0.4,
103 + "evidence": [x for x in (ev("availability", "fail_rate_z", "Failure rate {current:.0%} vs baseline {baseline:.1%}"),) if x]})
104 + if not out:
105 + out.append({"text": "Unknown regional connectivity issue", "confidence": 0.3,
106 + "evidence": [f"{COMPONENT_LABELS[k]} at {v:.0f}" for k, v in c.items() if v is not None and v >= 30]})
107 + return out
108 +
109 +
110 +def evidence_rows(sc: ScopeScore, now: datetime) -> list[dict[str, Any]]:
111 + rows = []
112 + for comp, sigs in sc.signals.items():
113 + for sid, s in sigs.items():
114 + if (s.get("stress") or 0) < 0.1:
115 + continue
116 + rows.append({"signal_id": sid, "component": comp, "label": sid, "scope_type": sc.scope_type, "scope_id": sc.scope_id,
117 + "current": s.get("current"), "baseline": s.get("baseline"), "robust_z": s.get("z"),
118 + "samples": s.get("samples") or s.get("n"), "stress": s.get("stress"), "ts": iso(now)})
119 + rows.sort(key=lambda r: -(r["stress"] or 0))
120 + return rows[:12]
121 +
122 +
123 +async def load_open_events() -> dict[tuple[str, str], dict[str, Any]]:
124 + rows = await pg.fetch("SELECT * FROM events WHERE status <> 'resolved'")
125 + return {(r["scope_type"], r["scope_id"] or ""): dict(r) for r in rows}
126 +
127 +
128 +async def step(ctx: Ctx, scopes: list[ScopeScore], *, routing_ok: bool, allowed: bool, global_pressure: float) -> list[tuple[str, dict[str, Any]]]:
129 + """Advance the event state machine. Returns [(sse_event_name, incident_dict)] for publication."""
130 + cfg = ctx.cfg.events
131 + detect = float(cfg.get("detect_threshold", 45))
132 + recover = float(cfg.get("recover_threshold", 30))
133 + confirm = int(cfg.get("confirm_cycles", 2))
134 + active_after = int(cfg.get("active_cycles", 6))
135 + resolve_after = int(cfg.get("resolve_after_seconds", 600))
136 + min_conf = float(cfg.get("min_confidence", 0.45))
137 + now = ctx.now
138 + open_events = await load_open_events()
139 + out: list[tuple[str, dict[str, Any]]] = []
140 + seen: set[tuple[str, str]] = set()
141 +
142 + for sc in scopes:
143 + if sc.scope_type not in EVENT_SCOPES:
144 + continue
145 + key = (sc.scope_type, sc.scope_id)
146 + ev = open_events.get(key)
147 + above = sc.pressure >= detect and sc.confidence >= min_conf
148 + if ev is None:
149 + if not allowed or not above:
150 + continue
151 + # candidate counter kept in Redis (cheap, no row until confirmed)
152 + ck = f"ip:evcand:{sc.scope_type}:{sc.scope_id}"
153 + n = int(await rds.r().incr(ck))
154 + await rds.r().expire(ck, 300)
155 + if n < confirm:
156 + continue
157 + await rds.r().delete(ck)
158 + dominant = _dominant(sc.components)
159 + eid = f"evt_{ULID()}"
160 + slug = slugify(f"{now.strftime('%Y-%m-%d')} {sc.label} {_event_type(sc.scope_type, dominant).replace('_', ' ')}")
161 + exists = await pg.fetchval("SELECT 1 FROM events WHERE slug=$1", slug)
162 + if exists:
163 + slug = f"{slug}-{now.strftime('%H%M')}"
164 + hyps = hypotheses(ctx, sc, routing_ok)
165 + evid = evidence_rows(sc, now)
166 + asns = sorted({int(a) for comp in sc.signals.values() for _ in comp.values() for a in []}) # filled below
167 + asns = sorted({t for t in _asns_of(ctx, sc)})[:20]
168 + services = sorted({ctx.targets[t]["service_id"] for t in sc.targets if t in ctx.targets and ctx.targets[t].get("service_id")})[:20]
169 + await pg.execute(
170 + """INSERT INTO events(event_id,slug,type,title,summary,status,scope_type,scope_id,scope_label,started_at,updated_at,
171 + peak_pressure,current_pressure,confidence,affected_probes,affected_targets,affected_asns,affected_services,
172 + hypotheses,evidence,probes,targets,cycles_above)
173 + VALUES($1,$2,$3,$4,$5,'detected',$6,$7,$8,$9,$9,$10,$10,$11,$12,$13,$14,$15,$16::jsonb,$17::jsonb,$18::jsonb,$19::jsonb,$20)""",
174 + eid, slug, _event_type(sc.scope_type, dominant), _title(ctx, sc, dominant), _summary(ctx, sc, dominant),
175 + sc.scope_type, sc.scope_id, sc.label, now, sc.pressure, sc.confidence, len(sc.probes), len(sc.targets),
176 + asns, services, dumps_str(hyps), dumps_str(evid),
177 + dumps_str([{"probe_id": p, "region": ctx.probes.get(p, {}).get("region")} for p in sorted(sc.probes)][:50]),
178 + dumps_str([{"target_id": t, "name": ctx.targets.get(t, {}).get("name"), "service_id": ctx.targets.get(t, {}).get("service_id")}
179 + for t in sorted(sc.targets)][:100]),
180 + confirm,
181 + )
182 + await pg.execute("INSERT INTO event_timeline(event_id,ts,status,pressure,note) VALUES($1,$2,'detected',$3,$4)",
183 + eid, now, sc.pressure, f"Pressure {sc.pressure:.0f} above {detect:.0f} for {confirm} cycles")
184 + row = await pg.fetchrow("SELECT * FROM events WHERE event_id=$1", eid)
185 + out.append(("incident_created", incident_dict(dict(row))))
186 + log.info("event created %s (%s %s) pressure=%.1f", slug, sc.scope_type, sc.scope_id, sc.pressure)
187 + continue
188 +
189 + seen.add(key)
190 + status = ev["status"]
191 + new_status = status
192 + cycles = int(ev["cycles_above"] or 0)
193 + below_since = ev["below_since"]
194 + if above:
195 + cycles += 1
196 + below_since = None
197 + if status in ("detected", "developing", "recovering"):
198 + new_status = "active" if cycles >= active_after else "developing"
199 + elif sc.pressure < recover:
200 + below_since = below_since or now
201 + if (now - below_since).total_seconds() >= resolve_after:
202 + new_status = "resolved"
203 + elif status != "detected":
204 + new_status = "recovering"
205 + else:
206 + new_status = "resolved" # a bare detection that never confirmed
207 + peak = max(float(ev["peak_pressure"]), sc.pressure)
208 + hyps = hypotheses(ctx, sc, routing_ok) if new_status != "resolved" else ev["hypotheses"]
209 + evid = evidence_rows(sc, now) if above else ev["evidence"]
210 + await pg.execute(
211 + """UPDATE events SET status=$2, updated_at=$3, ended_at=CASE WHEN $2='resolved' THEN $3 ELSE ended_at END,
212 + peak_pressure=$4, current_pressure=$5, confidence=GREATEST(confidence,$6), affected_probes=GREATEST(affected_probes,$7),
213 + affected_targets=GREATEST(affected_targets,$8), hypotheses=$9::jsonb, evidence=$10::jsonb, cycles_above=$11, below_since=$12
214 + WHERE event_id=$1""",
215 + ev["event_id"], new_status, now, peak, sc.pressure, sc.confidence, len(sc.probes), len(sc.targets),
216 + dumps_str(hyps), dumps_str(evid), cycles, below_since,
217 + )
218 + if new_status != status:
219 + note = {"developing": "Pressure still elevated; incident developing", "active": "Sustained elevated pressure",
220 + "recovering": f"Pressure fell below {recover:.0f}", "resolved": "Pressure back to normal for 10 minutes"}.get(new_status, "")
221 + await pg.execute("INSERT INTO event_timeline(event_id,ts,status,pressure,note) VALUES($1,$2,$3,$4,$5)",
222 + ev["event_id"], now, new_status, sc.pressure, note)
223 + row = await pg.fetchrow("SELECT * FROM events WHERE event_id=$1", ev["event_id"])
224 + out.append(("incident_updated", incident_dict(dict(row))))
225 + log.info("event %s → %s (%.1f)", ev["slug"], new_status, sc.pressure)
226 +
227 + # open events whose scope vanished from this cycle (e.g. ASN no longer observed): treat as below threshold
228 + for key, ev in open_events.items():
229 + if key in seen:
230 + continue
231 + below_since = ev["below_since"] or now
232 + if (now - below_since).total_seconds() >= resolve_after:
233 + await pg.execute("UPDATE events SET status='resolved', ended_at=$2, updated_at=$2 WHERE event_id=$1", ev["event_id"], now)
234 + await pg.execute("INSERT INTO event_timeline(event_id,ts,status,pressure,note) VALUES($1,$2,'resolved',NULL,'Scope no longer observed')",
235 + ev["event_id"], now)
236 + row = await pg.fetchrow("SELECT * FROM events WHERE event_id=$1", ev["event_id"])
237 + out.append(("incident_updated", incident_dict(dict(row))))
238 + else:
239 + await pg.execute("UPDATE events SET below_since=COALESCE(below_since,$2) WHERE event_id=$1", ev["event_id"], now)
240 + return out
241 +
242 +
243 +def _asns_of(ctx: Ctx, sc: ScopeScore) -> set[int]:
244 + out: set[int] = set()
245 + if sc.scope_type == "asn":
246 + out.add(int(sc.scope_id))
247 + for t in sc.targets:
248 + svc = ctx.targets.get(t, {}).get("service_id")
249 + if svc and svc in ctx.services:
250 + out.update(int(a) for a in (ctx.services[svc].get("asns") or [])[:2])
251 + return out
252 +
253 +
254 +def incident_dict(r: dict[str, Any]) -> dict[str, Any]:
255 + started, updated, ended = r["started_at"], r["updated_at"], r.get("ended_at")
256 + end_ref = ended or utcnow()
257 + return {
258 + "event_id": r["event_id"], "slug": r["slug"], "type": r["type"], "title": r["title"], "summary": r["summary"],
259 + "status": r["status"], "scope_type": r["scope_type"], "scope_id": r["scope_id"], "scope_label": r["scope_label"],
260 + "started_at": iso(started), "updated_at": iso(updated), "ended_at": iso(ended),
261 + "duration_s": int((end_ref - started).total_seconds()),
262 + "peak_pressure": round(float(r["peak_pressure"]), 1), "current_pressure": round(float(r["current_pressure"]), 1),
263 + "confidence": round(float(r["confidence"]), 2), "affected_probes": r["affected_probes"], "affected_targets": r["affected_targets"],
264 + "affected_asns": list(r["affected_asns"] or []), "affected_services": list(r["affected_services"] or []),
265 + "hypotheses": r["hypotheses"] or [], "review": r.get("review", "unreviewed"),
266 + }
added apps/api/src/internetpressure/engine/features.py +252 −0
@@ -0,0 +1,252 @@
1 +"""ClickHouse feature queries for the engine: current windows and rolling baselines.
2 +
3 +Baselines are refreshed every 5 minutes (cached in memory + Redis so a restart is warm) and read from the raw
4 +`measurements` table (180 d TTL); single pass with quantiles — MAD is estimated from the IQR (0.7413 × IQR)."""
5 +
6 +from __future__ import annotations
7 +
8 +import logging
9 +import time
10 +from dataclasses import dataclass, field
11 +from typing import Any
12 +
13 +from ..db import ch, rds
14 +from ..util import utcnow
15 +
16 +log = logging.getLogger("ip.features")
17 +
18 +LAT_METRICS = ("ttfb", "tcp", "tls", "dns", "rtt")
19 +
20 +
21 +@dataclass
22 +class Baseline:
23 + samples: int # distinct 5-minute buckets in the baseline window
24 + q: dict[str, tuple[float | None, float | None, float | None]] # metric → (p25, p50, p75)
25 + loss: float | None
26 + fail_rate: float | None
27 + rate_5xx: float | None
28 + rate_tls: float | None
29 + rate_reset: float | None
30 + dns_fail: float | None
31 +
32 +
33 +@dataclass
34 +class Baselines:
35 + pairs: dict[tuple[str, str, str, str], Baseline] = field(default_factory=dict) # (probe, target, kind, resolver)
36 + routes: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict) # (probe, target) → route baseline
37 + bgp: dict[str, dict[str, Any]] = field(default_factory=dict) # collector → baseline of per-minute totals
38 + bgp_origin: dict[int, dict[str, Any]] = field(default_factory=dict)
39 + history_days: float = 0.0
40 + computed_at: float = 0.0
41 + seasonal: bool = False
42 +
43 +
44 +def _q(row: dict, name: str) -> tuple[float | None, float | None, float | None]:
45 + v = row.get(name)
46 + if not v or len(v) < 3:
47 + return None, None, None
48 + return tuple(None if x is None else float(x) for x in v) # type: ignore[return-value]
49 +
50 +
51 +async def history_days() -> float:
52 + r = await ch.query_one("SELECT min(ts) AS m, count() AS n FROM measurements")
53 + if not r or not r.get("m") or not int(r.get("n") or 0):
54 + return 0.0
55 + from ..util import parse_ts
56 +
57 + m = parse_ts(r["m"])
58 + return max(0.0, (utcnow() - m).total_seconds() / 86400.0) if m else 0.0
59 +
60 +
61 +async def compute_baselines(cfg_engine: dict[str, Any]) -> Baselines:
62 + days = int(cfg_engine.get("baseline_days", 7))
63 + excl = int(cfg_engine.get("baseline_exclude_seconds", 600))
64 + hd = await history_days()
65 + seasonal = cfg_engine.get("seasonality") == "hour_of_day" and hd >= float(cfg_engine.get("seasonality_min_days", 3))
66 + hour = utcnow().hour
67 + season = f" AND (abs(toHour(ts) - {hour}) <= 1 OR abs(toHour(ts) - {hour}) >= 23)" if seasonal else ""
68 + where = f"ts >= now() - INTERVAL {days} DAY AND ts < now() - INTERVAL {excl} SECOND{season}"
69 +
70 + rows = await ch.query(f"""
71 + SELECT probe_id, target_id, kind, resolver,
72 + uniqExact(toStartOfFiveMinutes(ts)) AS samples,
73 + quantilesTDigest(0.25, 0.5, 0.75)(ttfb_ms) AS ttfb_q,
74 + quantilesTDigest(0.25, 0.5, 0.75)(tcp_ms) AS tcp_q,
75 + quantilesTDigest(0.25, 0.5, 0.75)(tls_ms) AS tls_q,
76 + quantilesTDigest(0.25, 0.5, 0.75)(dns_ms) AS dns_q,
77 + quantilesTDigest(0.25, 0.5, 0.75)(rtt_avg_ms) AS rtt_q,
78 + avg(packet_loss) AS loss,
79 + avg(ok = 0) AS fail_rate,
80 + avg(http_status >= 500) AS rate_5xx,
81 + avg(error IN ('tls_fail','tls_cert')) AS rate_tls,
82 + avg(error IN ('tcp_reset','reset','tcp_timeout','http_timeout')) AS rate_reset,
83 + avg(kind = 'dns' AND ok = 0) AS dns_fail
84 + FROM measurements WHERE {where}
85 + GROUP BY probe_id, target_id, kind, resolver
86 + """)
87 + b = Baselines(history_days=hd, computed_at=time.time(), seasonal=seasonal)
88 + for r in rows:
89 + b.pairs[(r["probe_id"], r["target_id"], r["kind"], r["resolver"] or "")] = Baseline(
90 + samples=int(r["samples"]),
91 + q={m: _q(r, f"{m}_q") for m in LAT_METRICS},
92 + loss=r.get("loss"), fail_rate=r.get("fail_rate"), rate_5xx=r.get("rate_5xx"), rate_tls=r.get("rate_tls"),
93 + rate_reset=r.get("rate_reset"), dns_fail=r.get("dns_fail"),
94 + )
95 +
96 + # route baselines: dominant route per pair, change rate per hour, hop-count quantiles, total_ms median
97 + rrows = await ch.query(f"""
98 + WITH per_pair AS (
99 + SELECT probe_id, target_id, route_hash, count() AS n, any(hop_count) AS hc, any(asn_path) AS asn_path,
100 + any(hop_ips) AS hop_ips, min(ts) AS first_seen, max(ts) AS last_seen
101 + FROM traceroutes WHERE ts >= now() - INTERVAL {days} DAY AND reached = 1
102 + GROUP BY probe_id, target_id, route_hash
103 + ),
104 + tot AS (
105 + SELECT probe_id, target_id, sum(n) AS total, count() AS distinct_routes,
106 + argMax(route_hash, n) AS dominant, max(n) AS dom_n
107 + FROM per_pair GROUP BY probe_id, target_id
108 + ),
109 + stats AS (
110 + SELECT probe_id, target_id,
111 + quantilesTDigest(0.25,0.5,0.75)(toFloat32(hop_count)) AS hop_q,
112 + quantilesTDigest(0.25,0.5,0.75)(total_ms) AS ms_q,
113 + countIf(changed) AS changes,
114 + count() AS n
115 + FROM (
116 + SELECT probe_id, target_id, hop_count, total_ms,
117 + route_hash != lagInFrame(route_hash, 1, route_hash) OVER (PARTITION BY probe_id, target_id ORDER BY ts) AS changed
118 + FROM traceroutes WHERE ts >= now() - INTERVAL {days} DAY
119 + )
120 + GROUP BY probe_id, target_id
121 + )
122 + SELECT t.probe_id AS probe_id, t.target_id AS target_id, t.total AS total, t.distinct_routes AS distinct_routes, t.dominant AS dominant, t.dom_n AS dom_n,
123 + p.hop_ips AS dom_hop_ips, p.asn_path AS dom_asn_path, p.first_seen, p.last_seen,
124 + s.hop_q AS hop_q, s.ms_q AS ms_q, s.changes AS changes, s.n AS n
125 + FROM tot t
126 + LEFT JOIN per_pair p ON p.probe_id = t.probe_id AND p.target_id = t.target_id AND p.route_hash = t.dominant
127 + LEFT JOIN stats s ON s.probe_id = t.probe_id AND s.target_id = t.target_id
128 + """)
129 + for r in rrows:
130 + n = int(r.get("n") or 0)
131 + b.routes[(r["probe_id"], r["target_id"])] = {
132 + "dominant": r["dominant"], "share": (float(r["dom_n"]) / float(r["total"])) if r["total"] else None,
133 + "dom_hop_ips": r.get("dom_hop_ips") or [], "dom_asn_path": r.get("dom_asn_path") or [],
134 + "first_seen": r.get("first_seen"), "last_seen": r.get("last_seen"),
135 + "hop_q": _q(r, "hop_q"), "ms_q": _q(r, "ms_q"),
136 + "change_rate": (float(r.get("changes") or 0) / n) if n else None, # changes per traceroute sample
137 + "samples": n, "distinct_routes": int(r.get("distinct_routes") or 0),
138 + }
139 +
140 + # BGP baselines per collector: per-minute totals over the horizon (seasonal if possible)
141 + bseason = season.replace("toHour(ts)", "toHour(m)") if seasonal else ""
142 + brows = await ch.query(f"""
143 + SELECT collector, quantilesTDigest(0.25,0.5,0.75)(a) AS a_q, quantilesTDigest(0.25,0.5,0.75)(w) AS w_q,
144 + quantilesTDigest(0.25,0.5,0.75)(o) AS o_q, count() AS samples
145 + FROM (
146 + SELECT collector, toStartOfMinute(ts) AS m, sum(announcements) AS a, sum(withdrawals) AS w, sum(origin_changes) AS o
147 + FROM bgp_stats_10s WHERE ts >= now() - INTERVAL {days} DAY AND ts < now() - INTERVAL {excl} SECOND
148 + GROUP BY collector, m
149 + ) WHERE 1{bseason}
150 + GROUP BY collector
151 + """)
152 + for r in brows:
153 + b.bgp[r["collector"]] = {"a_q": _q(r, "a_q"), "w_q": _q(r, "w_q"), "o_q": _q(r, "o_q"), "samples": int(r["samples"])}
154 +
155 + orows = await ch.query(f"""
156 + SELECT origin_asn, quantilesTDigest(0.25,0.5,0.75)(a) AS a_q, quantilesTDigest(0.25,0.5,0.75)(w) AS w_q, count() AS samples
157 + FROM (SELECT origin_asn, ts, sum(announcements) AS a, sum(withdrawals) AS w FROM bgp_origin_1m
158 + WHERE ts >= now() - INTERVAL {days} DAY AND ts < now() - INTERVAL {excl} SECOND GROUP BY origin_asn, ts)
159 + GROUP BY origin_asn HAVING samples >= 30
160 + """)
161 + for r in orows:
162 + b.bgp_origin[int(r["origin_asn"])] = {"a_q": _q(r, "a_q"), "w_q": _q(r, "w_q"), "samples": int(r["samples"])}
163 +
164 + log.info("baselines: %d pairs, %d routes, %d collectors, %d origins, history %.1f d, seasonal=%s",
165 + len(b.pairs), len(b.routes), len(b.bgp), len(b.bgp_origin), hd, seasonal)
166 + try:
167 + await rds.set_json("ip:engine:baselines_meta", {"pairs": len(b.pairs), "routes": len(b.routes),
168 + "collectors": len(b.bgp), "history_days": round(hd, 2),
169 + "seasonal": seasonal, "computed_at": time.time()}, ex=3600)
170 + except Exception: # noqa: BLE001
171 + pass
172 + return b
173 +
174 +
175 +async def current_pairs(window_s: int) -> list[dict[str, Any]]:
176 + return await ch.query(f"""
177 + SELECT probe_id, target_id, kind, resolver, count() AS n, sum(ok) AS ok_n,
178 + quantileTDigest(0.5)(ttfb_ms) AS ttfb, quantileTDigest(0.5)(tcp_ms) AS tcp, quantileTDigest(0.5)(tls_ms) AS tls,
179 + quantileTDigest(0.5)(dns_ms) AS dns, quantileTDigest(0.5)(rtt_avg_ms) AS rtt,
180 + avg(packet_loss) AS loss,
181 + sum(http_status >= 500) AS n_5xx,
182 + sum(error IN ('tls_fail','tls_cert')) AS n_tls,
183 + sum(error IN ('tcp_reset','reset','tcp_timeout','http_timeout')) AS n_reset,
184 + sum(kind = 'dns' AND ok = 0) AS n_dnsfail,
185 + anyLast(resolved_ip) AS resolved_ip, anyLast(dns_rcode) AS rcode, max(ts) AS last_ts
186 + FROM measurements WHERE ts >= now() - INTERVAL {window_s} SECOND
187 + GROUP BY probe_id, target_id, kind, resolver
188 + """)
189 +
190 +
191 +async def current_routes(window_s: int = 1800) -> list[dict[str, Any]]:
192 + return await ch.query(f"""
193 + SELECT probe_id, target_id, argMax(route_hash, ts) AS route_hash, argMax(hop_count, ts) AS hop_count,
194 + argMax(total_ms, ts) AS total_ms, argMax(reached, ts) AS reached, max(ts) AS last_ts,
195 + argMax(asn_path, ts) AS asn_path
196 + FROM traceroutes WHERE ts >= now() - INTERVAL {window_s} SECOND
197 + GROUP BY probe_id, target_id
198 + """)
199 +
200 +
201 +async def current_bgp(window_s: int) -> list[dict[str, Any]]:
202 + return await ch.query(f"""
203 + SELECT collector, sum(announcements) AS a, sum(withdrawals) AS w, sum(origin_changes) AS o,
204 + max(peers) AS peers, max(ts) AS last_ts, count() AS buckets
205 + FROM bgp_stats_10s WHERE ts >= now() - INTERVAL {window_s} SECOND
206 + GROUP BY collector
207 + """)
208 +
209 +
210 +async def current_bgp_origins(asns: list[int], window_s: int = 300) -> dict[int, dict[str, float]]:
211 + if not asns:
212 + return {}
213 + rows = await ch.query(f"""
214 + SELECT origin_asn, sum(announcements) AS a, sum(withdrawals) AS w, count() AS minutes
215 + FROM bgp_origin_1m WHERE ts >= now() - INTERVAL {window_s} SECOND AND origin_asn IN ({','.join(str(int(a)) for a in asns)})
216 + GROUP BY origin_asn
217 + """)
218 + return {int(r["origin_asn"]): {"a": float(r["a"]) / max(1, int(r["minutes"])), "w": float(r["w"]) / max(1, int(r["minutes"]))}
219 + for r in rows}
220 +
221 +
222 +async def recent_global_history(minutes: int = 60) -> list[dict[str, Any]]:
223 + return await ch.query(f"""
224 + SELECT ts, pressure FROM pressure_history
225 + WHERE scope_type = 'global' AND ts >= now() - INTERVAL {minutes} MINUTE ORDER BY ts
226 + """)
227 +
228 +
229 +async def pressure_at(scope_type: str, scope_id: str, minutes_ago: int) -> float | None:
230 + r = await ch.query_one(f"""
231 + SELECT argMax(pressure, ts) AS p FROM pressure_history
232 + WHERE scope_type = '{scope_type}' AND scope_id = '{scope_id}'
233 + AND ts BETWEEN now() - INTERVAL {minutes_ago + 2} MINUTE AND now() - INTERVAL {max(0, minutes_ago - 2)} MINUTE
234 + """)
235 + return float(r["p"]) if r and r.get("p") is not None else None
236 +
237 +
238 +async def sparkline(scope_type: str, scope_id: str, minutes: int = 60) -> list[float | None]:
239 + rows = await ch.query(f"""
240 + SELECT toStartOfMinute(ts) AS m, avg(pressure) AS p FROM pressure_history
241 + WHERE scope_type = '{scope_type}' AND scope_id = '{scope_id}' AND ts >= now() - INTERVAL {minutes} MINUTE
242 + GROUP BY m ORDER BY m
243 + """)
244 + from ..util import parse_ts
245 +
246 + by_min: dict[int, float] = {}
247 + for r in rows:
248 + t = parse_ts(r["m"])
249 + if t:
250 + by_min[int(t.timestamp() // 60)] = float(r["p"])
251 + now_min = int(utcnow().timestamp() // 60)
252 + return [by_min.get(now_min - (minutes - 1) + i) for i in range(minutes)]
added apps/api/src/internetpressure/engine/fronts.py +99 −0
@@ -0,0 +1,99 @@
1 +"""Pressure Fronts: source-region → destination-region corridors where several pairs are stressed at once."""
2 +
3 +from __future__ import annotations
4 +
5 +from collections import defaultdict
6 +from typing import Any
7 +
8 +from ..util import bearing_to_direction, iso, r1, utcnow
9 +from . import scoring as S
10 +from .compute import Ctx, Sig
11 +
12 +FRONT_NAMES = {
13 + frozenset({"na-east", "eu-west"}): "North Atlantic Pressure Front",
14 + frozenset({"na-east", "eu-north"}): "North Atlantic Pressure Front",
15 + frozenset({"na-east", "eu-east-med"}): "Transatlantic–Mediterranean Pressure Front",
16 + frozenset({"eu-west", "eu-east-med"}): "European Pressure Front",
17 + frozenset({"na-east", "asia-east"}): "Transpacific Pressure Front",
18 + frozenset({"na-west", "asia-east"}): "Transpacific Pressure Front",
19 + frozenset({"eu-west", "asia-south"}): "Eurasian Pressure Front",
20 + frozenset({"eu-east-med", "asia-south"}): "Eurasian Pressure Front",
21 + frozenset({"na-east", "latam"}): "Americas Pressure Front",
22 + frozenset({"eu-west", "africa"}): "Euro-African Pressure Front",
23 +}
24 +
25 +
26 +def compute_fronts(ctx: Ctx, sigs: list[Sig], previous: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
27 + cfg = ctx.cfg.fronts
28 + min_pairs = int(cfg.get("min_pairs", 3))
29 + min_intensity = float(cfg.get("min_intensity", 35))
30 + k = float(ctx.eng.get("saturation_k", 1.2))
31 + prev = {f["id"]: f for f in (previous or [])}
32 +
33 + corridors: dict[tuple[str, str], dict[str, Any]] = defaultdict(lambda: {
34 + "pairs": set(), "targets": set(), "stress": [], "lat": [], "loss": [], "route_changes": 0, "churn_cur": 0.0,
35 + "churn_base": 0.0, "n_route": 0,
36 + })
37 + for s in sigs:
38 + if s.component not in ("latency", "path"):
39 + continue
40 + src, dst = s.tags.src_region, s.tags.dst_region
41 + if src == "global" or dst == "global" or src == dst or s.tags.probe_id == "*":
42 + continue
43 + c = corridors[(src, dst)]
44 + c["stress"].append(S.PairStress(s.pair.key, s.pair.stress, s.pair.weight))
45 + if s.pair.stress >= 0.25:
46 + c["pairs"].add((s.tags.probe_id, s.tags.target_id))
47 + c["targets"].add(s.tags.target_id)
48 + if s.signal_id in ("ttfb_z", "rtt_z", "tcp_z") and s.pair.current and s.pair.baseline:
49 + c["lat"].append((s.pair.current - s.pair.baseline) / s.pair.baseline * 100.0)
50 + if s.signal_id == "loss" and s.pair.current is not None:
51 + c["loss"].append(s.pair.current * 100.0)
52 + if s.signal_id == "route_change_rate":
53 + c["n_route"] += 1
54 + c["churn_cur"] += s.pair.current or 0.0
55 + c["churn_base"] += s.pair.baseline or 0.0
56 + if (s.pair.current or 0) >= 1.0:
57 + c["route_changes"] += 1
58 +
59 + fronts: list[dict[str, Any]] = []
60 + for (src, dst), c in corridors.items():
61 + stress, tw, n = S.weighted_stress(c["stress"])
62 + if len(c["pairs"]) < min_pairs:
63 + continue
64 + intensity = S.saturate(stress, k)
65 + if intensity < min_intensity:
66 + continue
67 + fid = f"front_{src}_{dst}"
68 + rs, rd = ctx.regions.get(src), ctx.regions.get(dst)
69 + if not rs or not rd:
70 + continue
71 + old = prev.get(fid)
72 + since = old["since"] if old else iso(utcnow())
73 + status = "developing"
74 + if old:
75 + if intensity > old["intensity"] + 3:
76 + status = "developing"
77 + elif intensity < old["intensity"] - 3:
78 + status = "recovering"
79 + else:
80 + status = "active"
81 + conf = S.confidence_score(probes=len({p for p, _ in c["pairs"]}), probe_regions=1,
82 + signals_agreeing=(1 if c["lat"] else 0) + (1 if c["route_changes"] else 0) + (1 if c["loss"] else 0),
83 + samples=len(c["stress"]), min_samples=10)
84 + churn_x = (c["churn_cur"] / c["churn_base"]) if c["churn_base"] > 0 else (float(c["route_changes"]) if c["route_changes"] else None)
85 + fronts.append({
86 + "id": fid, "name": FRONT_NAMES.get(frozenset({src, dst}), f"{rs.name} → {rd.name} Pressure Front"),
87 + "status": status, "intensity": r1(intensity), "confidence": conf,
88 + "direction": bearing_to_direction(rs.lat, rs.lon, rd.lat, rd.lon), "since": since,
89 + "from": {"region": src, "name": rs.name, "lat": rs.lat, "lon": rs.lon},
90 + "to": {"region": dst, "name": rd.name, "lat": rd.lat, "lon": rd.lon},
91 + "observed": {
92 + "latency_pct": r1(sorted(c["lat"])[len(c["lat"]) // 2]) if c["lat"] else None,
93 + "churn_x": r1(churn_x) if churn_x is not None else None,
94 + "loss_pct": r1(sum(c["loss"]) / len(c["loss"])) if c["loss"] else None,
95 + "pairs": len(c["pairs"]), "targets": len(c["targets"]), "route_changes": c["route_changes"],
96 + },
97 + })
98 + fronts.sort(key=lambda f: -(f["intensity"] or 0))
99 + return fronts[:6]
added apps/api/src/internetpressure/engine/health.py +103 −0
@@ -0,0 +1,103 @@
1 +"""Self-exclusion (spec §57): decide whether the instrument itself is healthy enough to publish a score.
2 +
3 +Rules
4 +-----
5 +* a probe is *fresh* if a batch arrived within `probe_fresh_seconds`;
6 +* a probe is *excluded* when ≥ `probe_local_failure_ratio` of its HTTP targets fail in the window while the median
7 + probe is fine (its own uplink is broken, not the Internet);
8 +* if fewer than `min_probes_for_scoring` fresh, non-excluded probes remain, or every probe fails at once, or a store is
9 + down, the engine reports `internal_status = degraded`, keeps the last published value frozen (`stale: true`) and
10 + does not open incidents.
11 +"""
12 +
13 +from __future__ import annotations
14 +
15 +from collections import defaultdict
16 +from dataclasses import dataclass, field
17 +from datetime import timedelta
18 +from typing import Any
19 +
20 +from ..db import ch, pg, rds
21 +from ..util import parse_ts, utcnow
22 +
23 +
24 +@dataclass
25 +class HealthState:
26 + internal_status: str = "ok" # ok | degraded | stale
27 + reasons: list[str] = field(default_factory=list)
28 + probes_fresh: list[str] = field(default_factory=list)
29 + probes_total: int = 0
30 + excluded: list[str] = field(default_factory=list)
31 + excluded_reasons: dict[str, str] = field(default_factory=dict)
32 + bgp_fresh: bool = False
33 + bgp_last: str | None = None
34 + stores: dict[str, bool] = field(default_factory=dict)
35 + last_batch: str | None = None
36 +
37 + def scoring_allowed(self) -> bool:
38 + return self.internal_status == "ok"
39 +
40 +
41 +async def probe_freshness(probe_ids: list[str], fresh_s: int) -> tuple[list[str], str | None]:
42 + if not probe_ids:
43 + return [], None
44 + vals = await rds.r().mget([f"ip:probe:{p}:seen" for p in probe_ids])
45 + fresh: list[str] = []
46 + latest = None
47 + now = utcnow()
48 + for pid, v in zip(probe_ids, vals, strict=True):
49 + if not v:
50 + continue
51 + ts = parse_ts(v.decode())
52 + if not ts:
53 + continue
54 + if latest is None or ts > latest:
55 + latest = ts
56 + if now - ts <= timedelta(seconds=fresh_s):
57 + fresh.append(pid)
58 + from ..util import iso
59 +
60 + return fresh, iso(latest) if latest else None
61 +
62 +
63 +def local_failures(cur_rows: list[dict[str, Any]], fresh: list[str], ratio: float) -> tuple[set[str], dict[str, str], bool]:
64 + """Probes whose own uplink looks broken. Returns (excluded, reasons, everyone_failing)."""
65 + fails: dict[str, list[float]] = defaultdict(list)
66 + for r in cur_rows:
67 + if r["kind"] != "http" or r["probe_id"] not in fresh:
68 + continue
69 + n = int(r["n"]) or 1
70 + fails[r["probe_id"]].append(1.0 - float(r["ok_n"]) / n)
71 + rates = {p: (sum(v) / len(v)) for p, v in fails.items() if v}
72 + if not rates:
73 + return set(), {}, False
74 + med = sorted(rates.values())[len(rates) // 2]
75 + excluded = {p for p, fr in rates.items() if fr >= ratio and med < 0.3}
76 + everyone = all(fr >= ratio for fr in rates.values()) and len(rates) >= 2
77 + reasons = {p: f"{rates[p]*100:.0f} % of its HTTP checks failing while other probes are normal" for p in excluded}
78 + return excluded, reasons, everyone
79 +
80 +
81 +async def evaluate(probe_ids: list[str], cur_rows: list[dict[str, Any]], eng: dict[str, Any]) -> HealthState:
82 + h = HealthState(probes_total=len(probe_ids))
83 + h.stores = {"clickhouse": await ch.healthy(), "postgres": await pg.healthy(), "redis": await rds.healthy()}
84 + for name, ok in h.stores.items():
85 + if not ok:
86 + h.reasons.append(f"{name} unavailable")
87 + fresh, last = await probe_freshness(probe_ids, int(eng.get("probe_fresh_seconds", 180)))
88 + h.probes_fresh, h.last_batch = fresh, last
89 + excluded, reasons, everyone = local_failures(cur_rows, fresh, float(eng.get("probe_local_failure_ratio", 0.8)))
90 + h.excluded, h.excluded_reasons = sorted(excluded), reasons
91 + usable = [p for p in fresh if p not in excluded]
92 + if len(usable) < int(eng.get("min_probes_for_scoring", 2)):
93 + h.reasons.append(f"only {len(usable)} usable probe(s) (need {eng.get('min_probes_for_scoring', 2)})")
94 + if everyone:
95 + h.reasons.append("every probe is failing simultaneously — cannot separate our ingestion from the Internet")
96 + bgp = await rds.get_json("ip:bgp:rate")
97 + if bgp and bgp.get("ts"):
98 + ts = parse_ts(bgp["ts"])
99 + h.bgp_last = bgp["ts"]
100 + h.bgp_fresh = bool(ts and (utcnow() - ts).total_seconds() <= int(eng.get("bgp_fresh_seconds", 120)))
101 + if h.reasons:
102 + h.internal_status = "degraded"
103 + return h
added apps/api/src/internetpressure/engine/loop.py +457 −0
@@ -0,0 +1,457 @@
1 +"""The engine cycle (every `engine.cycle_seconds`):
2 +
3 +fetch registry + config → health/self-exclusion → current features → pair signals → scope scores → global index,
4 +velocity, explainability → events → fronts → publish (Redis live state + pub/sub, ClickHouse history/provenance)."""
5 +
6 +from __future__ import annotations
7 +
8 +import asyncio
9 +import logging
10 +import time
11 +from typing import Any
12 +
13 +from ..asn import asndb
14 +from ..config import COMPONENT_IDS
15 +from ..db import ch, pg, rds
16 +from ..regions import COUNTRY_CENTROIDS, country_name, load_regions
17 +from ..registry import bump_config_version, list_probes, list_services, list_targets, load_config
18 +from ..settings import get_settings
19 +from ..util import ch_ts, iso, parse_ts, r1, r2, utcnow
20 +from . import scoring as S
21 +from .compute import (Ctx, RoutingResult, ScopeScore, asn_routing_stress, bucketize, component_rows, corroboration_stress,
22 + explain_global, label_for, latency_matrix, probe_signals, routing_signals, score_scope)
23 +from .events import incident_dict, step as events_step
24 +from .features import (Baselines, compute_baselines, current_bgp, current_bgp_origins, current_pairs, current_routes,
25 + pressure_at, recent_global_history, sparkline)
26 +from .fronts import compute_fronts
27 +from .health import evaluate as evaluate_health
28 +
29 +log = logging.getLogger("ip.engine")
30 +
31 +
32 +class Engine:
33 + def __init__(self) -> None:
34 + self.baselines: Baselines | None = None
35 + self.baselines_at = 0.0
36 + self.last_global: dict[str, Any] | None = None
37 + self.last_regions: list[dict[str, Any]] = []
38 + self.last_fronts: list[dict[str, Any]] = []
39 + self.prev_components_1h: dict[str, float | None] = {}
40 + self.cycle_ms: list[int] = []
41 + self.runs = 0
42 + self._cfg_weights: dict[str, float] = {}
43 +
44 + async def cycle(self) -> None:
45 + t0 = time.perf_counter()
46 + now = utcnow()
47 + cfg = await load_config()
48 + self._cfg_weights = cfg.weights
49 + eng = cfg.engine
50 + regions = load_regions()
51 + probes = {p["probe_id"]: p for p in await list_probes(enabled_only=True)}
52 + targets = {t["target_id"]: t for t in await list_targets(enabled_only=True)}
53 + services = {s["slug"]: s for s in await list_services()}
54 +
55 + window = int(eng.get("window_seconds", 120))
56 + cur_rows = await current_pairs(window)
57 + health = await evaluate_health(list(probes), cur_rows, eng)
58 +
59 + if self.baselines is None or time.time() - self.baselines_at > 300:
60 + try:
61 + self.baselines = await compute_baselines(eng)
62 + self.baselines_at = time.time()
63 + except Exception as exc: # noqa: BLE001
64 + log.exception("baseline computation failed: %s", exc)
65 + if self.baselines is None:
66 + self.baselines = Baselines()
67 + ctx = Ctx(cfg=cfg, regions=regions, probes=probes, targets=targets, services=services, baselines=self.baselines,
68 + now=now, asn_of_ip=asndb.lookup, excluded_probes=set(health.excluded))
69 +
70 + # ── features → signals
71 + route_rows = await current_routes(1800)
72 + bgp_rows = await current_bgp(int(eng.get("bgp_window_seconds", 60)))
73 + vendor_rows = [dict(r) for r in await pg.fetch("SELECT * FROM vendor_status")]
74 + sigs = probe_signals(ctx, cur_rows, route_rows)
75 + routing = routing_signals(ctx, bgp_rows, int(eng.get("bgp_window_seconds", 60)), fresh=health.bgp_fresh)
76 + corr_stress, corr_active = corroboration_stress(vendor_rows, services)
77 +
78 + # ── scopes
79 + buckets = bucketize(sigs)
80 + scopes: dict[tuple[str, str], ScopeScore] = {}
81 + g = score_scope(ctx, "global", "global", "Global", buckets.get(("global", "global"), []),
82 + routing=routing.stresses if routing.stresses else None, routing_details=routing.details,
83 + corroboration=corr_stress)
84 + scopes[("global", "global")] = g
85 + # every region/country with probes or targets gets a scope (even if quiet → score 0 with coverage)
86 + for rid in regions.regions:
87 + if rid == "global":
88 + continue
89 + scopes[("region", rid)] = score_scope(ctx, "region", rid, label_for(ctx, "region", rid), buckets.get(("region", rid), []),
90 + routing=None, routing_details=None, corroboration=None)
91 + ccs = {p["country"] for p in probes.values() if p.get("country")} | {t["country"] for t in targets.values() if t.get("country")}
92 + for cc in sorted(ccs):
93 + scopes[("country", cc)] = score_scope(ctx, "country", cc, country_name(cc), buckets.get(("country", cc), []),
94 + routing=None, routing_details=None, corroboration=None)
95 + for slug in services:
96 + vs = next((v for v in vendor_rows if v["service_slug"] == slug), None)
97 + cstress = None
98 + if vs and vs.get("ok"):
99 + from .compute import IMPACT
100 +
101 + cstress = IMPACT.get(vs.get("indicator") or "none", 0.0)
102 + scopes[("service", slug)] = score_scope(ctx, "service", slug, label_for(ctx, "service", slug), buckets.get(("service", slug), []),
103 + routing=None, routing_details=None, corroboration=cstress)
104 + asn_keys = [k for k in buckets if k[0] == "asn"]
105 + asn_cur = await current_bgp_origins([int(k[1]) for k in asn_keys]) if asn_keys else {}
106 + asn_meta: dict[str, dict[str, Any]] = {}
107 + for k in asn_keys:
108 + asn = int(k[1])
109 + rstress, rdet = asn_routing_stress(ctx, asn, asn_cur.get(asn))
110 + sw = cfg.signal_weights("routing")
111 + routing_map = {"bgp_withdrawals_z": rstress, "bgp_announcements_z": rstress} if rstress is not None else None
112 + scopes[k] = score_scope(ctx, "asn", k[1], asndb.name(asn) or f"AS{asn}", buckets[k],
113 + routing=routing_map, routing_details={"bgp_withdrawals_z": rdet, "bgp_announcements_z": rdet} if rdet else None,
114 + corroboration=None)
115 + asn_meta[k[1]] = {"name": asndb.name(asn), "country": asndb.country(asn), "bgp": rdet}
116 + _ = sw
117 +
118 + # ── global payload
119 + allowed = health.scoring_allowed()
120 + prev = self.last_global
121 + # calibrating = less than half of the configured weight has a scored component (cold start: baselines missing)
122 + avail_w = sum(cfg.weights.get(c, 0.0) for c, v in g.components.items() if v is not None)
123 + calibrating = avail_w < 0.5
124 + if allowed and not calibrating:
125 + pressure = g.pressure
126 + elif allowed:
127 + pressure = None # healthy instrument, but no signal has a baseline yet
128 + else:
129 + pressure = prev["pressure"] if prev else None
130 + level, level_label = cfg.level_for(pressure)
131 + if allowed and calibrating:
132 + level, level_label = "calibrating", "Calibrating baselines"
133 + p1h = await pressure_at("global", "global", 60)
134 + p24 = await pressure_at("global", "global", 1440)
135 + hist = await recent_global_history(60)
136 + pts = [(parse_ts(h["ts"]).timestamp(), float(h["pressure"])) for h in hist if parse_ts(h["ts"])]
137 + vel, acc = S.velocity(pts)
138 + vol = S.volatility([v for _, v in pts])
139 + explain, drivers = explain_global(ctx, g, sigs, routing if routing.stresses else None, corr_active)
140 + if not self.prev_components_1h or self.runs % 30 == 0:
141 + self.prev_components_1h = await self._components_1h_ago()
142 + delta_1h = (pressure - p1h) if (pressure is not None and p1h is not None) else None
143 + coverage = {
144 + "probes_active": len([p for p in health.probes_fresh if p not in health.excluded]), "probes_total": health.probes_total,
145 + "probe_regions": len({probes[p]["region"] for p in health.probes_fresh if p in probes}),
146 + "targets": len(targets), "measurements_5m": await self._measurements_last(5),
147 + "bgp_collectors": len(bgp_rows), "baseline_days": round(self.baselines.history_days, 2) if self.baselines else 0,
148 + "baseline_seasonal": bool(self.baselines and self.baselines.seasonal),
149 + }
150 + global_payload = {
151 + "ts": iso(now), "pressure": r1(pressure), "level": level, "level_label": level_label,
152 + "delta_1h": r1(delta_1h), "delta_24h": r1(pressure - p24) if (pressure is not None and p24 is not None) else None,
153 + "velocity_per_h": r1(vel), "acceleration_per_h2": r1(acc), "volatility_1h": r1(vol), "trend": S.trend_word(delta_1h),
154 + "confidence": g.confidence if allowed else (prev or {}).get("confidence", 0),
155 + "stale": not allowed, "internal_status": health.internal_status, "internal_reasons": health.reasons,
156 + "excluded_probes": [{"probe_id": p, "reason": health.excluded_reasons.get(p)} for p in health.excluded],
157 + "coverage": coverage, "components": component_rows(ctx, g, drivers, self.prev_components_1h) if allowed else (prev or {}).get("components", []),
158 + "explain": explain if allowed else (prev or {}).get("explain", []),
159 + "frozen_since": (prev or {}).get("frozen_since") or (iso(now) if not allowed else None) if not allowed else None,
160 + }
161 + global_payload["sparkline_1h"] = await sparkline("global", "global", 60)
162 +
163 + # ── regions / countries / services / asns payloads
164 + regions_out = []
165 + for rid, reg in regions.regions.items():
166 + if rid == "global":
167 + continue
168 + sc = scopes[("region", rid)]
169 + n_probes = sum(1 for p in probes.values() if p["region"] == rid)
170 + n_targets = sum(1 for t in targets.values() if t["region"] == rid)
171 + if n_probes == 0 and n_targets == 0:
172 + continue
173 + role = "both" if n_probes and n_targets else ("probe" if n_probes else "target")
174 + pr = sc.pressure if allowed else None
175 + lv, ll = cfg.level_for(pr)
176 + prev_r = await pressure_at("region", rid, 60)
177 + regions_out.append({
178 + "id": rid, "name": reg.name, "continent": reg.continent, "lat": reg.lat, "lon": reg.lon,
179 + "pressure": r1(pr), "level": lv, "level_label": ll,
180 + "delta_1h": r1(pr - prev_r) if (pr is not None and prev_r is not None) else None,
181 + "trend": S.trend_word((pr - prev_r) if (pr is not None and prev_r is not None) else None),
182 + "confidence": sc.confidence, "components": {c: sc.components.get(c) for c in COMPONENT_IDS if c not in ("corroboration",)},
183 + "probes": n_probes, "targets": n_targets, "incidents": 0, "coverage_ok": sc.weight_total >= 3.0 and len(sc.probes) >= 1,
184 + "role": role,
185 + })
186 + countries_out = []
187 + for cc in sorted(ccs):
188 + sc = scopes[("country", cc)]
189 + n_probes = sum(1 for p in probes.values() if p.get("country") == cc)
190 + n_targets = sum(1 for t in targets.values() if t.get("country") == cc)
191 + pr = sc.pressure if allowed else None
192 + lv, ll = cfg.level_for(pr)
193 + lat, lon = COUNTRY_CENTROIDS.get(cc, (0.0, 0.0))
194 + countries_out.append({
195 + "cc": cc, "name": country_name(cc), "region": regions.region_of_country(cc), "lat": lat, "lon": lon,
196 + "pressure": r1(pr), "level": lv, "level_label": ll, "delta_1h": None, "trend": "stable",
197 + "components": {c: sc.components.get(c) for c in COMPONENT_IDS if c not in ("corroboration",)},
198 + "probes": n_probes, "targets": n_targets, "role": "both" if n_probes and n_targets else ("probe" if n_probes else "target"),
199 + "coverage_ok": sc.weight_total >= 2.0, "confidence": sc.confidence,
200 + })
201 + services_out = []
202 + for slug, svc in services.items():
203 + sc = scopes[("service", slug)]
204 + vs = next((v for v in vendor_rows if v["service_slug"] == slug), None)
205 + pr = sc.pressure if allowed else None
206 + lv, ll = cfg.level_for(pr)
207 + affected = sorted({s.tags.src_region for s in buckets.get(("service", slug), []) if s.pair.stress >= 0.4 and s.tags.probe_id != "*"})
208 + services_out.append({
209 + "slug": slug, "name": svc["name"], "category": svc.get("category"), "importance": svc.get("importance"),
210 + "pressure": r1(pr), "level": lv, "level_label": ll, "confidence": sc.confidence,
211 + "components": {c: sc.components.get(c) for c in COMPONENT_IDS if c != "routing"},
212 + "targets": sum(1 for t in targets.values() if t.get("service_id") == slug), "affected_regions": affected,
213 + "vendor_status": ({"indicator": vs["indicator"], "incidents": vs["incidents"], "titles": vs.get("titles") or [],
214 + "source": vs.get("source"), "url": vs.get("url"), "checked_at": iso(vs["checked_at"]), "ok": vs["ok"]}
215 + if vs else None),
216 + })
217 + asns_out = []
218 + for k in asn_keys:
219 + sc = scopes[k]
220 + pr = sc.pressure if allowed else None
221 + lv, ll = cfg.level_for(pr)
222 + asns_out.append({
223 + "asn": int(k[1]), "name": asn_meta[k[1]]["name"], "country": asn_meta[k[1]]["country"],
224 + "pressure": r1(pr), "level": lv, "level_label": ll, "confidence": sc.confidence,
225 + "components": {c: sc.components.get(c) for c in COMPONENT_IDS if c != "corroboration"},
226 + "routing": sc.components.get("routing"), "latency": sc.components.get("latency"), "availability": sc.components.get("availability"),
227 + "targets": len(sc.targets), "regions_observed": sorted({probes[p]["region"] for p in sc.probes if p in probes}),
228 + "bgp": asn_meta[k[1]]["bgp"], "importance": max((targets[t]["importance"] for t in sc.targets if t in targets), default=2),
229 + })
230 + asns_out.sort(key=lambda a: -(a["pressure"] or 0))
231 +
232 + # ── events & fronts (only while the instrument is healthy)
233 + published_events: list[tuple[str, dict[str, Any]]] = []
234 + try:
235 + published_events = await events_step(ctx, list(scopes.values()), routing_ok=health.bgp_fresh, allowed=allowed,
236 + global_pressure=g.pressure)
237 + except Exception as exc: # noqa: BLE001
238 + log.exception("event engine failed: %s", exc)
239 + fronts = compute_fronts(ctx, sigs, self.last_fronts) if allowed else self.last_fronts
240 + active_incidents = [incident_dict(dict(r)) for r in await pg.fetch(
241 + "SELECT * FROM events WHERE status <> 'resolved' ORDER BY current_pressure DESC LIMIT 50")]
242 + inc_by_region = {}
243 + for inc in active_incidents:
244 + if inc["scope_type"] == "region":
245 + inc_by_region[inc["scope_id"]] = inc_by_region.get(inc["scope_id"], 0) + 1
246 + for r in regions_out:
247 + r["incidents"] = inc_by_region.get(r["id"], 0)
248 +
249 + # ── sampling boost for stressed targets
250 + await self._boost(ctx, sigs)
251 +
252 + # ── latency matrix + ticker extras (real counts from the current window / last hour)
253 + lat = latency_matrix(ctx, sigs, cur_rows)
254 + lat["ts"] = iso(now)
255 + dns_fail_2m = sum(int(r["n"]) - int(r["ok_n"]) for r in cur_rows if r["kind"] == "dns")
256 + route_changes_1h = sum(1 for s in sigs if s.signal_id == "route_change_rate" and (s.pair.current or 0) >= 1.0)
257 + extra = {"dns_failures_per_min": round(dns_fail_2m / max(1.0, window / 60.0), 1), "route_changes_per_min": round(route_changes_1h / 60.0, 2)}
258 + targets_by_asn = {k[1]: sorted({s.tags.target_id for s in buckets[k]}) for k in asn_keys}
259 + await rds.set_json("ip:live:latency", lat)
260 + await rds.set_json("ip:live:extra", extra)
261 + await rds.set_json("ip:live:targets_by_asn", targets_by_asn)
262 +
263 + # ── publish
264 + await self._publish(now, health, global_payload, regions_out, countries_out, services_out, asns_out, fronts,
265 + active_incidents, routing, g, scopes, sigs, published_events, corr_active, allowed)
266 + self.last_global = global_payload
267 + self.last_regions = regions_out
268 + self.last_fronts = fronts
269 + ms = int((time.perf_counter() - t0) * 1000)
270 + self.cycle_ms.append(ms)
271 + self.cycle_ms = self.cycle_ms[-360:]
272 + self.runs += 1
273 + try:
274 + await ch.insert("engine_runs", [{"ts": ch_ts(now), "cycle_ms": ms, "internal_status": health.internal_status,
275 + "probes_fresh": len(health.probes_fresh), "probes_excluded": len(health.excluded),
276 + "pairs": len(cur_rows), "error": ""}])
277 + except Exception: # noqa: BLE001
278 + pass
279 + log.info("cycle %d: pressure=%s level=%s status=%s probes=%d/%d pairs=%d sigs=%d bgp=%s %dms",
280 + self.runs, global_payload["pressure"], level, health.internal_status, len(health.probes_fresh),
281 + health.probes_total, len(cur_rows), len(sigs), "fresh" if health.bgp_fresh else "stale", ms)
282 +
283 + async def _components_1h_ago(self) -> dict[str, float | None]:
284 + rows = await ch.query("""
285 + SELECT argMax(routing, ts) AS routing, argMax(latency, ts) AS latency, argMax(dns, ts) AS dns,
286 + argMax(availability, ts) AS availability, argMax(http_tls, ts) AS http_tls, argMax(path, ts) AS path,
287 + argMax(corroboration, ts) AS corroboration
288 + FROM pressure_history WHERE scope_type='global' AND ts BETWEEN now() - INTERVAL 62 MINUTE AND now() - INTERVAL 58 MINUTE
289 + """)
290 + return {c: rows[0].get(c) for c in COMPONENT_IDS} if rows else {}
291 +
292 + async def _measurements_last(self, minutes: int) -> int:
293 + now = utcnow()
294 + keys = [(now.replace(second=0, microsecond=0) - __import__("datetime").timedelta(minutes=i)).strftime("%Y%m%d%H%M") for i in range(minutes)]
295 + try:
296 + return await rds.sum_minutes("meas", keys)
297 + except Exception: # noqa: BLE001
298 + return 0
299 +
300 + async def _boost(self, ctx: Ctx, sigs) -> None: # type: ignore[no-untyped-def]
301 + sch = ctx.cfg.scheduler
302 + stressed: dict[str, set[str]] = {}
303 + for s in sigs:
304 + if s.pair.stress >= 0.5 and s.tags.probe_id != "*":
305 + stressed.setdefault(s.tags.target_id, set()).add(s.tags.probe_id)
306 + boosted = sorted(t for t, ps in stressed.items() if len(ps) >= 2)[:40]
307 + cur = await rds.get_json("ip:boost") or {}
308 + if boosted:
309 + until = utcnow() + __import__("datetime").timedelta(seconds=int(sch.get("boost_seconds", 900)))
310 + new = {"targets": boosted, "factor": float(sch.get("boost_factor", 0.5)), "until": iso(until)}
311 + if set(cur.get("targets") or []) != set(boosted):
312 + await rds.set_json("ip:boost", new, ex=int(sch.get("boost_seconds", 900)) + 60)
313 + await bump_config_version()
314 + else:
315 + await rds.set_json("ip:boost", new, ex=int(sch.get("boost_seconds", 900)) + 60)
316 + elif cur:
317 + # let it expire naturally, but drop the list if it was set by us and nothing is stressed anymore
318 + until = parse_ts(cur.get("until"))
319 + if until and until < utcnow():
320 + await rds.r().delete("ip:boost")
321 + await bump_config_version()
322 +
323 + async def _publish(self, now, health, gp, regions_out, countries_out, services_out, asns_out, fronts, incidents,
324 + routing: RoutingResult, g: ScopeScore, scopes, sigs, published_events, corr_active, allowed) -> None: # type: ignore[no-untyped-def]
325 + # explain (deep)
326 + explain_deep = {
327 + "ts": iso(now), "pressure": gp["pressure"], "internal_status": health.internal_status,
328 + "components": [
329 + {"id": c, "score": g.components.get(c), "weight": g.contributions and None, "contribution": g.contributions.get(c),
330 + "signals": [
331 + {"signal_id": sid, "label": None, "scope_type": "global", "scope_id": None, **{k: (r2(v) if isinstance(v, float) else v) for k, v in s.items()}}
332 + for sid, s in g.signals.get(c, {}).items()
333 + ]}
334 + for c in COMPONENT_IDS
335 + ],
336 + "excluded_probes": health.excluded, "notes": health.reasons,
337 + "regions": [{"id": r["id"], "components": r["components"], "pressure": r["pressure"]} for r in regions_out],
338 + }
339 + pipe = rds.r().pipeline()
340 + from ..util import dumps
341 +
342 + pipe.set("ip:live:global", dumps(gp))
343 + pipe.set("ip:live:regions", dumps(regions_out))
344 + pipe.set("ip:live:countries", dumps(countries_out))
345 + pipe.set("ip:live:services", dumps(services_out))
346 + pipe.set("ip:live:asns", dumps(asns_out))
347 + pipe.set("ip:live:fronts", dumps(fronts))
348 + pipe.set("ip:live:incidents", dumps(incidents))
349 + pipe.set("ip:live:explain", dumps(explain_deep))
350 + pipe.set("ip:live:status", dumps({
351 + "ts": iso(now), "internal_status": health.internal_status, "reasons": health.reasons,
352 + "probes": {"fresh": len(health.probes_fresh), "total": health.probes_total, "excluded": health.excluded},
353 + "bgp": {"fresh": health.bgp_fresh, "last_message": health.bgp_last, "collectors": len(routing.collectors)},
354 + "stores": health.stores, "last_batch": health.last_batch,
355 + }))
356 + pipe.set("ip:live:routing", dumps({
357 + "ts": iso(now), "fresh": routing.fresh, "updates_per_s": r2(routing.updates_per_s),
358 + "announcements_per_s": r2(routing.announcements_per_s), "withdrawals_per_s": r2(routing.withdrawals_per_s),
359 + "baseline": {"announcements_per_s": r2(routing.baseline_a_per_s), "withdrawals_per_s": r2(routing.baseline_w_per_s)},
360 + "ratio": {"announcements": r2(routing.announcements_per_s / routing.baseline_a_per_s) if routing.baseline_a_per_s else None,
361 + "withdrawals": r2(routing.withdrawals_per_s / routing.baseline_w_per_s) if routing.baseline_w_per_s else None},
362 + "collectors": routing.collectors, "score": g.components.get("routing"),
363 + }))
364 + pipe.set("ip:engine:last_run", iso(now).encode())
365 + pipe.set("ip:engine:cycle_ms", str(self.cycle_ms[-1] if self.cycle_ms else 0).encode())
366 + pipe.set("ip:engine:internal_status", health.internal_status.encode())
367 + await pipe.execute()
368 +
369 + await rds.publish("global_pressure_update", gp)
370 + await rds.publish("regional_pressure_update", {"ts": iso(now), "regions": regions_out,
371 + "countries": [{"cc": c["cc"], "pressure": c["pressure"], "level": c["level"], "delta_1h": c["delta_1h"]} for c in countries_out]})
372 + await rds.publish("front_update", {"ts": iso(now), "fronts": fronts})
373 + await rds.publish("internal_status", {"ts": iso(now), "internal_status": health.internal_status, "reason": "; ".join(health.reasons) or None})
374 + for name, inc in published_events:
375 + await rds.publish(name, inc)
376 + for svc in services_out:
377 + if svc["pressure"] is not None and svc["pressure"] >= 45 and svc["affected_regions"]:
378 + await rds.publish("service_degradation", {"ts": iso(now), "slug": svc["slug"], "name": svc["name"], "pressure": svc["pressure"],
379 + "regions": svc["affected_regions"], "observation": f"Pressure {svc['pressure']:.0f} from {len(svc['affected_regions'])} probe region(s)"})
380 +
381 + # ClickHouse history + provenance (only real computations; a frozen instrument writes the frozen value flagged)
382 + rows = []
383 + calibrating = sum(self._cfg_weights.get(c, 0.0) for c, v in g.components.items() if v is not None) < 0.5
384 + for (stype, sid), sc in scopes.items():
385 + if not allowed or calibrating:
386 + break # a frozen or uncalibrated instrument writes no history (spec §57/§66)
387 + if stype in ("country",) and sc.weight_total <= 0:
388 + continue
389 + rows.append({"ts": ch_ts(now), "scope_type": stype, "scope_id": sid, "pressure": sc.pressure,
390 + "confidence": sc.confidence, **{c: sc.components.get(c) for c in COMPONENT_IDS},
391 + "internal_status": health.internal_status})
392 + feats = []
393 + for comp, sigmap in g.signals.items():
394 + for sid, s in sigmap.items():
395 + feats.append({"ts": ch_ts(now), "component": comp, "signal_id": sid, "scope_type": "global", "scope_id": "global",
396 + "current": _f(s.get("current")), "baseline": _f(s.get("baseline")), "mad": _f(s.get("mad")),
397 + "robust_z": _f(s.get("z")), "samples": int(s.get("samples") or s.get("n") or 0),
398 + "stress": float(s.get("stress") or 0), "contribution": float(s.get("contribution") or 0)})
399 + for (stype, sid), sc in scopes.items():
400 + if stype != "region":
401 + continue
402 + for comp, sigmap in sc.signals.items():
403 + for sg, s in sigmap.items():
404 + if (s.get("stress") or 0) < 0.05:
405 + continue
406 + feats.append({"ts": ch_ts(now), "component": comp, "signal_id": sg, "scope_type": stype, "scope_id": sid,
407 + "current": _f(s.get("current")), "baseline": _f(s.get("baseline")), "mad": _f(s.get("mad")),
408 + "robust_z": _f(s.get("z")), "samples": int(s.get("samples") or s.get("n") or 0),
409 + "stress": float(s.get("stress") or 0), "contribution": float(s.get("contribution") or 0)})
410 + try:
411 + await ch.insert("pressure_history", rows)
412 + await ch.insert("signal_features", feats)
413 + except Exception as exc: # noqa: BLE001
414 + log.warning("history insert failed: %s", exc)
415 +
416 +
417 +def _f(v: Any) -> float | None:
418 + try:
419 + return None if v is None else float(v)
420 + except (TypeError, ValueError):
421 + return None
422 +
423 +
424 +async def main(*, once: bool = False) -> None:
425 + s = get_settings()
426 + await pg.migrate()
427 + await ch.migrate()
428 + try:
429 + await asndb.refresh()
430 + except Exception as exc: # noqa: BLE001
431 + log.warning("asn db unavailable: %s", exc)
432 + eng = Engine()
433 + cycle = s.engine_cycle_s or int((await load_config()).engine.get("cycle_seconds", 10))
434 + log.info("pressure engine started (cycle %ss)", cycle)
435 + last_asn_refresh = time.time()
436 + while True:
437 + t0 = time.time()
438 + try:
439 + await eng.cycle()
440 + except Exception as exc: # noqa: BLE001
441 + log.exception("cycle failed: %s", exc)
442 + try:
443 + await rds.set_json("ip:live:status", {"ts": iso(utcnow()), "internal_status": "degraded", "reasons": [f"engine error: {str(exc)[:200]}"]})
444 + await ch.insert("engine_runs", [{"ts": ch_ts(utcnow()), "cycle_ms": int((time.time() - t0) * 1000), "internal_status": "error",
445 + "probes_fresh": 0, "probes_excluded": 0, "pairs": 0, "error": str(exc)[:500]}])
446 + except Exception: # noqa: BLE001
447 + pass
448 + if once:
449 + break
450 + if time.time() - last_asn_refresh > 6 * 3600:
451 + last_asn_refresh = time.time()
452 + try:
453 + await asndb.refresh()
454 + except Exception: # noqa: BLE001
455 + pass
456 + cycle = s.engine_cycle_s or int((await load_config()).engine.get("cycle_seconds", 10))
457 + await asyncio.sleep(max(1.0, cycle - (time.time() - t0)))
added apps/api/src/internetpressure/engine/replay.py +39 −0
@@ -0,0 +1,39 @@
1 +"""Replay: recompute the global index over a time range from stored component history with alternative weights.
2 +(Component scores are re-weighted; re-deriving components from raw measurements with alternative engine parameters
3 +is a longer offline job — see docs/ARCHITECTURE.md § Replay.)"""
4 +
5 +from __future__ import annotations
6 +
7 +from typing import Any
8 +
9 +from ..config import COMPONENT_IDS
10 +from ..db import ch
11 +from ..registry import load_config
12 +from ..util import parse_ts
13 +from . import scoring as S
14 +
15 +
16 +async def replay_range(start: str, end: str, weights: dict[str, float] | None, step_seconds: int | None = None) -> list[dict[str, Any]]:
17 + cfg = await load_config()
18 + w = {k: float(v) for k, v in (weights or cfg.weights).items()}
19 + if abs(sum(w.values()) - 1.0) > 0.001:
20 + raise ValueError("weights must sum to 1.0")
21 + s = parse_ts(start)
22 + e = parse_ts(end)
23 + if not s or not e or e <= s:
24 + raise ValueError("bad range")
25 + span = (e - s).total_seconds()
26 + step = step_seconds or (10 if span <= 3600 else 60 if span <= 86400 else 300 if span <= 7 * 86400 else 3600)
27 + rows = await ch.query(f"""
28 + SELECT toStartOfInterval(ts, INTERVAL {step} SECOND) AS b, avg(pressure) AS pressure,
29 + {', '.join(f'avg({c}) AS {c}' for c in COMPONENT_IDS)}
30 + FROM pressure_history WHERE scope_type = 'global' AND ts >= {{s:DateTime64(3)}} AND ts < {{e:DateTime64(3)}}
31 + GROUP BY b ORDER BY b
32 + """, params={"s": s.strftime("%Y-%m-%d %H:%M:%S.000"), "e": e.strftime("%Y-%m-%d %H:%M:%S.000")})
33 + out = []
34 + for r in rows:
35 + comps = {c: (float(r[c]) if r.get(c) is not None else None) for c in COMPONENT_IDS}
36 + replayed, _ = S.global_score(comps, w)
37 + out.append({"ts": r["b"], "pressure_original": round(float(r["pressure"]), 2), "pressure_replayed": round(replayed, 2),
38 + "components": comps})
39 + return out
added apps/api/src/internetpressure/engine/scoring.py +218 −0
@@ -0,0 +1,218 @@
1 +"""Pure scoring functions — no I/O, fully unit-tested (tests/test_scoring.py).
2 +
3 +Vocabulary
4 +----------
5 +robust z (x − median) / MAD, clipped to [z_clip_low, z_clip_high]. MAD is floored to avoid explosions on
6 + very tight baselines (see `robust_z`).
7 +pair stress 0..1 for one (probe, target[, resolver]) series: 0 at z ≤ 1, 1 at z ≥ 6 — `z_to_stress`.
8 +signal stress 0..1 importance-weighted mean of pair stresses (plus coverage damping) — `weighted_stress`.
9 +component 0..100 = saturate(Σ signal_weight × signal_stress) — `component_score`.
10 +global 0..100 = Σ component_weight × component_score (weights renormalised over available components).
11 +"""
12 +
13 +from __future__ import annotations
14 +
15 +import math
16 +from dataclasses import dataclass, field
17 +from typing import Iterable
18 +
19 +MAD_TO_SIGMA = 1.4826 # MAD × 1.4826 ≈ σ for a normal distribution
20 +IQR_TO_MAD = 0.7413 # MAD ≈ 0.7413 × IQR (single-pass estimator used when only quantiles are available)
21 +
22 +
23 +def mad_from_iqr(p25: float | None, p75: float | None) -> float | None:
24 + if p25 is None or p75 is None:
25 + return None
26 + return max(0.0, (p75 - p25) * IQR_TO_MAD)
27 +
28 +
29 +def robust_z(
30 + current: float | None, median: float | None, mad: float | None, *, mad_floor_abs: float = 1.0,
31 + mad_floor_rel: float = 0.05, clip_low: float = -3.0, clip_high: float = 8.0,
32 +) -> float | None:
33 + """(current − median) / MAD with a floor on the MAD: max(mad, mad_floor_abs, mad_floor_rel × |median|)."""
34 + if current is None or median is None or mad is None:
35 + return None
36 + floor = max(mad_floor_abs, mad_floor_rel * abs(median))
37 + denom = max(mad, floor)
38 + if denom <= 0:
39 + return None
40 + z = (current - median) / denom
41 + return max(clip_low, min(clip_high, z))
42 +
43 +
44 +def z_to_stress(z: float | None, *, z0: float = 1.0, z1: float = 6.0) -> float:
45 + """Linear ramp: 0 below z0, 1 above z1. Only positive deviations are stress."""
46 + if z is None or z <= z0:
47 + return 0.0
48 + if z >= z1:
49 + return 1.0
50 + return (z - z0) / (z1 - z0)
51 +
52 +
53 +def rate_stress(current: float | None, baseline: float | None, *, scale: float = 0.25, base_mult: float = 3.0) -> float:
54 + """Stress for a failure *rate* (0..1). Excess over the baseline rate, normalised by max(scale, base_mult×baseline).
55 + A rate of `scale` above a ~0 baseline gives stress 1 (e.g. 25 % of checks failing)."""
56 + if current is None:
57 + return 0.0
58 + base = baseline or 0.0
59 + excess = current - base
60 + if excess <= 0:
61 + return 0.0
62 + denom = max(scale, base_mult * base)
63 + return min(1.0, excess / denom)
64 +
65 +
66 +def ratio_stress(current: float | None, baseline: float | None, *, x0: float = 1.5, x1: float = 6.0) -> float:
67 + """Stress from a multiplicative ratio current/baseline (e.g. route churn 4.8× normal): 0 at ≤x0, 1 at ≥x1."""
68 + if current is None or baseline is None or baseline <= 0:
69 + return 0.0
70 + r = current / baseline
71 + if r <= x0:
72 + return 0.0
73 + if r >= x1:
74 + return 1.0
75 + return (r - x0) / (x1 - x0)
76 +
77 +
78 +def saturate(stress: float, k: float = 1.2) -> float:
79 + """Map stress 0..1 → score 0..100 with a concave saturating curve normalised so stress 1 → 100."""
80 + stress = max(0.0, min(1.0, stress))
81 + if k <= 0:
82 + return 100.0 * stress
83 + return 100.0 * (1.0 - math.exp(-k * stress)) / (1.0 - math.exp(-k))
84 +
85 +
86 +def coverage_factor(samples: int, min_samples: int) -> float:
87 + """Damping for weak baselines: 0 below 20 % of min_samples, linear to 1 at min_samples."""
88 + if min_samples <= 0:
89 + return 1.0
90 + if samples <= 0:
91 + return 0.0
92 + lo = 0.2 * min_samples
93 + if samples < lo:
94 + return 0.0
95 + return min(1.0, (samples - lo) / (min_samples - lo)) if min_samples > lo else 1.0
96 +
97 +
98 +@dataclass
99 +class PairStress:
100 + key: str
101 + stress: float
102 + weight: float # importance weight × coverage
103 + z: float | None = None
104 + current: float | None = None
105 + baseline: float | None = None
106 + mad: float | None = None
107 + samples: int = 0
108 + meta: dict = field(default_factory=dict)
109 +
110 +
111 +def weighted_stress(pairs: Iterable[PairStress]) -> tuple[float, float, int]:
112 + """Importance-weighted mean stress → (stress, total_weight, n)."""
113 + tw = 0.0
114 + acc = 0.0
115 + n = 0
116 + for p in pairs:
117 + if p.weight <= 0:
118 + continue
119 + acc += p.stress * p.weight
120 + tw += p.weight
121 + n += 1
122 + if tw <= 0:
123 + return 0.0, 0.0, 0
124 + return acc / tw, tw, n
125 +
126 +
127 +def breadth(pairs: Iterable[PairStress], *, threshold: float = 0.4) -> float:
128 + """Weighted share of pairs whose stress exceeds the threshold (how wide an anomaly is)."""
129 + tw = 0.0
130 + hit = 0.0
131 + for p in pairs:
132 + if p.weight <= 0:
133 + continue
134 + tw += p.weight
135 + if p.stress >= threshold:
136 + hit += p.weight
137 + return hit / tw if tw > 0 else 0.0
138 +
139 +
140 +def component_score(signal_stresses: dict[str, float], signal_weights: dict[str, float], *, k: float = 1.2) -> float:
141 + """Σ weight × stress over the signals that have a value (weights renormalised), then saturated to 0..100."""
142 + tw = 0.0
143 + acc = 0.0
144 + for sid, w in signal_weights.items():
145 + if sid in signal_stresses and signal_stresses[sid] is not None:
146 + acc += w * max(0.0, min(1.0, signal_stresses[sid]))
147 + tw += w
148 + if tw <= 0:
149 + return 0.0
150 + return saturate(acc / tw, k)
151 +
152 +
153 +def global_score(component_scores: dict[str, float | None], weights: dict[str, float]) -> tuple[float, dict[str, float]]:
154 + """Weighted sum over available components (weights renormalised). Returns (score, contributions)."""
155 + avail = {c: s for c, s in component_scores.items() if s is not None and c in weights}
156 + tw = sum(weights[c] for c in avail)
157 + if tw <= 0:
158 + return 0.0, {}
159 + contributions = {c: weights[c] / tw * s for c, s in avail.items()}
160 + return min(100.0, max(0.0, sum(contributions.values()))), contributions
161 +
162 +
163 +def confidence_score(*, probes: int, probe_regions: int, signals_agreeing: int, samples: int, min_samples: int,
164 + duration_s: float = 0.0, bgp_corroborated: bool = False, external_corroborated: bool = False) -> float:
165 + """Heuristic 0..1 confidence used for scores and incidents (documented in docs/ARCHITECTURE.md)."""
166 + c = 0.0
167 + c += min(probes, 6) / 6 * 0.30
168 + c += min(probe_regions, 4) / 4 * 0.20
169 + c += min(signals_agreeing, 3) / 3 * 0.20
170 + c += coverage_factor(samples, min_samples) * 0.15
171 + c += min(duration_s, 900) / 900 * 0.05
172 + if bgp_corroborated:
173 + c += 0.05
174 + if external_corroborated:
175 + c += 0.05
176 + return round(min(1.0, c), 3)
177 +
178 +
179 +def velocity(points: list[tuple[float, float]]) -> tuple[float | None, float | None]:
180 + """(velocity per hour, acceleration per hour²) from (t_seconds, value) points via least squares on the
181 + first/second half. Needs ≥ 4 points spanning ≥ 5 minutes."""
182 + pts = [(t, v) for t, v in points if v is not None]
183 + if len(pts) < 4 or pts[-1][0] - pts[0][0] < 300:
184 + return None, None
185 +
186 + def slope(seg: list[tuple[float, float]]) -> float | None:
187 + n = len(seg)
188 + if n < 2:
189 + return None
190 + mt = sum(t for t, _ in seg) / n
191 + mv = sum(v for _, v in seg) / n
192 + den = sum((t - mt) ** 2 for t, _ in seg)
193 + if den == 0:
194 + return None
195 + return sum((t - mt) * (v - mv) for t, v in seg) / den * 3600.0
196 +
197 + v_all = slope(pts)
198 + half = len(pts) // 2
199 + v1, v2 = slope(pts[:half]), slope(pts[half:])
200 + acc = None
201 + if v1 is not None and v2 is not None:
202 + dt_h = ((pts[-1][0] + pts[half][0]) / 2 - (pts[half - 1][0] + pts[0][0]) / 2) / 3600.0
203 + acc = (v2 - v1) / dt_h if dt_h > 0 else None
204 + return v_all, acc
205 +
206 +
207 +def volatility(values: list[float]) -> float | None:
208 + vals = [v for v in values if v is not None]
209 + if len(vals) < 3:
210 + return None
211 + m = sum(vals) / len(vals)
212 + return math.sqrt(sum((v - m) ** 2 for v in vals) / (len(vals) - 1))
213 +
214 +
215 +def trend_word(delta: float | None, *, eps: float = 1.0) -> str:
216 + if delta is None or abs(delta) < eps:
217 + return "stable"
218 + return "rising" if delta > 0 else "falling"
added apps/api/src/internetpressure/ingest/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""Probe ingestion: signed batches → validation → ClickHouse (buffered) + Redis live counters."""
added apps/api/src/internetpressure/ingest/auth.py +43 −0
@@ -0,0 +1,43 @@
1 +"""HMAC request signing shared with the Go agent (docs/PROBE-PROTOCOL.md).
2 +
3 +canonical = METHOD \n PATH \n TIMESTAMP \n sha256_hex(body)
4 +signature = hex(HMAC-SHA256(key_bytes, canonical))
5 +"""
6 +
7 +from __future__ import annotations
8 +
9 +import hashlib
10 +import hmac
11 +import time
12 +
13 +
14 +def body_sha256(body: bytes) -> str:
15 + return hashlib.sha256(body).hexdigest()
16 +
17 +
18 +def canonical(method: str, path: str, timestamp: str, body: bytes) -> bytes:
19 + return f"{method.upper()}\n{path}\n{timestamp}\n{body_sha256(body)}".encode()
20 +
21 +
22 +def sign(key_hex: str, method: str, path: str, timestamp: str, body: bytes) -> str:
23 + key = bytes.fromhex(key_hex)
24 + return hmac.new(key, canonical(method, path, timestamp, body), hashlib.sha256).hexdigest()
25 +
26 +
27 +def verify(key_hex: str, method: str, path: str, timestamp: str, body: bytes, signature: str, *,
28 + max_skew_s: int = 300, now: float | None = None) -> tuple[bool, str]:
29 + """Returns (ok, reason). reason ∈ {"ok", "skew", "bad_timestamp", "bad_signature"}."""
30 + try:
31 + ts = int(timestamp)
32 + except (TypeError, ValueError):
33 + return False, "bad_timestamp"
34 + now_s = now if now is not None else time.time()
35 + if abs(now_s - ts) > max_skew_s:
36 + return False, "skew"
37 + try:
38 + expected = sign(key_hex, method, path, timestamp, body)
39 + except ValueError:
40 + return False, "bad_signature"
41 + if not hmac.compare_digest(expected, (signature or "").lower()):
42 + return False, "bad_signature"
43 + return True, "ok"
added apps/api/src/internetpressure/ingest/models.py +111 −0
@@ -0,0 +1,111 @@
1 +"""Pydantic models for the probe batch payload (strict enough to reject garbage, lenient on optional fields)."""
2 +
3 +from __future__ import annotations
4 +
5 +from typing import Literal
6 +
7 +from pydantic import BaseModel, Field, field_validator
8 +
9 +ERROR_CODES = {
10 + "", "dns_fail", "dns_timeout", "dns_servfail", "dns_nxdomain", "tcp_timeout", "tcp_refused", "tcp_reset", "tls_fail",
11 + "tls_cert", "http_timeout", "http_5xx", "http_4xx", "reset", "unreachable", "icmp_unavailable", "other",
12 +}
13 +
14 +
15 +class Measurement(BaseModel):
16 + ts: str
17 + target_id: str = Field(min_length=1, max_length=80)
18 + kind: Literal["http", "dns", "ping", "tcp"]
19 + ok: bool
20 + error: str = ""
21 + dns_ms: float | None = None
22 + tcp_ms: float | None = None
23 + tls_ms: float | None = None
24 + ttfb_ms: float | None = None
25 + total_ms: float | None = None
26 + http_status: int | None = None
27 + http_proto: str | None = None
28 + tls_version: str | None = None
29 + resolved_ip: str | None = None
30 + resolver: str | None = None
31 + dns_rcode: str | None = None
32 + dns_answers: list[str] | None = None
33 + sent: int | None = None
34 + received: int | None = None
35 + packet_loss: float | None = None
36 + rtt_min_ms: float | None = None
37 + rtt_avg_ms: float | None = None
38 + rtt_max_ms: float | None = None
39 + jitter_ms: float | None = None
40 +
41 + @field_validator("error")
42 + @classmethod
43 + def _err(cls, v: str) -> str:
44 + return v if v in ERROR_CODES else "other"
45 +
46 + @field_validator("dns_ms", "tcp_ms", "tls_ms", "ttfb_ms", "total_ms", "rtt_min_ms", "rtt_avg_ms", "rtt_max_ms",
47 + "jitter_ms")
48 + @classmethod
49 + def _ms(cls, v: float | None) -> float | None:
50 + if v is None:
51 + return None
52 + return max(0.0, min(float(v), 120_000.0))
53 +
54 + @field_validator("packet_loss")
55 + @classmethod
56 + def _loss(cls, v: float | None) -> float | None:
57 + return None if v is None else max(0.0, min(float(v), 1.0))
58 +
59 +
60 +class Hop(BaseModel):
61 + n: int
62 + ip: str
63 + rtt_ms: float | None = None
64 +
65 +
66 +class Traceroute(BaseModel):
67 + ts: str
68 + target_id: str
69 + dest_ip: str
70 + reached: bool
71 + hop_count: int
72 + total_ms: float | None = None
73 + route_hash: str
74 + hops: list[Hop] = Field(default_factory=list, max_length=64)
75 +
76 +
77 +class Identity(BaseModel):
78 + public_ip: str | None = None
79 + asn: int | None = None
80 + org: str | None = None
81 + country: str | None = None
82 + city: str | None = None
83 + lat: float | None = None
84 + lon: float | None = None
85 + source: str | None = None
86 +
87 +
88 +class Health(BaseModel):
89 + ts: str
90 + agent_version: str = ""
91 + uptime_s: int = 0
92 + buffered: int = 0
93 + spool_bytes: int = 0
94 + measurements_total: int = 0
95 + errors_total: int = 0
96 + clock_offset_ms: int = 0
97 + rss_mb: float = 0
98 + goroutines: int = 0
99 + capabilities: list[str] = Field(default_factory=list)
100 + os: str | None = None
101 + arch: str | None = None
102 + identity: Identity | None = None
103 +
104 +
105 +class Batch(BaseModel):
106 + probe_id: str
107 + agent_version: str = ""
108 + sent_at: str | None = None
109 + measurements: list[Measurement] = Field(default_factory=list)
110 + traceroutes: list[Traceroute] = Field(default_factory=list)
111 + health: Health | None = None
added apps/api/src/internetpressure/ingest/router.py +260 −0
@@ -0,0 +1,260 @@
1 +"""/ingest/v1 — probe configuration and batch ingestion (signed)."""
2 +
3 +from __future__ import annotations
4 +
5 +import gzip
6 +import json
7 +import logging
8 +import time
9 +from typing import Any
10 +
11 +from fastapi import APIRouter, HTTPException, Request, Response
12 +
13 +from ..asn import asndb
14 +from ..db import rds
15 +from ..registry import config_version, get_probe, list_targets, load_config, touch_probe
16 +from ..settings import get_settings
17 +from ..util import ch_ts, dumps, iso, parse_ts, utcnow
18 +from . import auth
19 +from .models import Batch
20 +from .writer import writer
21 +
22 +log = logging.getLogger("ip.ingest")
23 +router = APIRouter(prefix="/ingest/v1", tags=["ingest"])
24 +
25 +_probe_cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
26 +_last_touch: dict[str, float] = {}
27 +
28 +RESOLVERS = [
29 + {"id": "system", "address": ""},
30 + {"id": "google", "address": "8.8.8.8:53"},
31 + {"id": "cloudflare", "address": "1.1.1.1:53"},
32 + {"id": "quad9", "address": "9.9.9.9:53"},
33 +]
34 +
35 +
36 +async def _probe(probe_id: str) -> dict[str, Any] | None:
37 + now = time.time()
38 + hit = _probe_cache.get(probe_id)
39 + if hit and now - hit[0] < 30:
40 + return hit[1]
41 + p = await get_probe(probe_id, with_key=True)
42 + _probe_cache[probe_id] = (now, p)
43 + return p
44 +
45 +
46 +def invalidate_probe_cache(probe_id: str | None = None) -> None:
47 + if probe_id:
48 + _probe_cache.pop(probe_id, None)
49 + else:
50 + _probe_cache.clear()
51 +
52 +
53 +async def _authenticate(request: Request, body: bytes) -> dict[str, Any]:
54 + s = get_settings()
55 + probe_id = request.headers.get("X-IP-Probe", "")
56 + ts = request.headers.get("X-IP-Timestamp", "")
57 + sig = request.headers.get("X-IP-Signature", "")
58 + if not probe_id or not ts or not sig:
59 + raise HTTPException(401, {"error": "unauthorized", "reason": "missing_headers"})
60 + p = await _probe(probe_id)
61 + if not p:
62 + raise HTTPException(401, {"error": "unauthorized", "reason": "unknown_probe"})
63 + ok, reason = auth.verify(p["key"], request.method, request.url.path, ts, body, sig, max_skew_s=s.ingest_max_skew_s)
64 + if not ok:
65 + raise HTTPException(401, {"error": "unauthorized", "reason": reason, "server_time": iso(utcnow())})
66 + # replay guard
67 + key = f"ip:replay:{probe_id}:{ts}:{auth.body_sha256(body)[:16]}"
68 + try:
69 + if not await rds.r().set(key, b"1", ex=600, nx=True):
70 + raise HTTPException(401, {"error": "unauthorized", "reason": "replay"})
71 + except HTTPException:
72 + raise
73 + except Exception as exc: # noqa: BLE001 — Redis down must not block ingestion
74 + log.warning("replay guard unavailable: %s", exc)
75 + return p
76 +
77 +
78 +def _client_ip(request: Request) -> str | None:
79 + if get_settings().trust_proxy:
80 + xff = request.headers.get("x-forwarded-for")
81 + if xff:
82 + return xff.split(",")[0].strip()
83 + return request.client.host if request.client else None
84 +
85 +
86 +async def _schedule() -> dict[str, Any]:
87 + cfg = await load_config()
88 + sch = cfg.scheduler
89 + boost = await rds.get_json("ip:boost") or None
90 + if boost and (parse_ts(boost.get("until")) or utcnow()) < utcnow():
91 + boost = None
92 + return {
93 + "tiers": {str(k): int(v) for k, v in (sch.get("tiers") or {1: 20, 2: 45, 3: 180}).items()},
94 + "dns_every": int(sch.get("dns_every", 60)),
95 + "ping_every": int(sch.get("ping_every", 30)),
96 + "traceroute_every": int(sch.get("traceroute_every", 900)),
97 + "batch_flush_seconds": int(sch.get("batch_flush_seconds", 10)),
98 + "max_batch": int(sch.get("max_batch", 500)),
99 + "config_refresh_seconds": int(sch.get("config_refresh_seconds", 300)),
100 + "boost": boost,
101 + }
102 +
103 +
104 +@router.get("/config")
105 +async def probe_config(request: Request) -> dict[str, Any]:
106 + p = await _authenticate(request, b"")
107 + targets = await list_targets(enabled_only=True)
108 + out_targets = [
109 + {
110 + "target_id": t["target_id"], "name": t["name"], "hostname": t["hostname"], "url": t["url"], "ip": t["ip"],
111 + "port": t["port"], "category": t["category"], "provider": t["provider"], "service_id": t["service_id"],
112 + "country": t["country"], "region": t["region"], "importance": t["importance"], "tier": t["tier"],
113 + "checks": t["checks"], "traceroute": bool(t["traceroute"]),
114 + }
115 + for t in targets
116 + ]
117 + return {
118 + "server_time": iso(utcnow()),
119 + "config_version": await config_version(),
120 + "probe": {k: p[k] for k in ("probe_id", "name", "region", "country", "city", "provider", "asn", "lat", "lon", "enabled")},
121 + "schedule": await _schedule(),
122 + "resolvers": RESOLVERS,
123 + "targets": out_targets,
124 + }
125 +
126 +
127 +def _m_row(probe_id: str, m: Any) -> dict[str, Any] | None:
128 + ts = parse_ts(m.ts)
129 + if ts is None:
130 + return None
131 + return {
132 + "ts": ch_ts(ts), "probe_id": probe_id, "target_id": m.target_id, "kind": m.kind, "resolver": m.resolver or "",
133 + "ok": 1 if m.ok else 0, "error": m.error or "", "dns_ms": m.dns_ms, "tcp_ms": m.tcp_ms, "tls_ms": m.tls_ms,
134 + "ttfb_ms": m.ttfb_ms, "total_ms": m.total_ms, "http_status": int(m.http_status or 0),
135 + "http_proto": m.http_proto or "", "tls_version": m.tls_version or "", "resolved_ip": m.resolved_ip or "",
136 + "dns_rcode": m.dns_rcode or "", "dns_answers": m.dns_answers or [], "sent": int(m.sent or 0),
137 + "received": int(m.received or 0), "packet_loss": m.packet_loss, "rtt_min_ms": m.rtt_min_ms,
138 + "rtt_avg_ms": m.rtt_avg_ms, "rtt_max_ms": m.rtt_max_ms, "jitter_ms": m.jitter_ms,
139 + }
140 +
141 +
142 +def _t_row(probe_id: str, t: Any) -> dict[str, Any] | None:
143 + ts = parse_ts(t.ts)
144 + if ts is None:
145 + return None
146 + ips = [h.ip for h in t.hops]
147 + return {
148 + "ts": ch_ts(ts), "probe_id": probe_id, "target_id": t.target_id, "dest_ip": t.dest_ip,
149 + "reached": 1 if t.reached else 0, "hop_count": min(int(t.hop_count), 255), "total_ms": t.total_ms,
150 + "route_hash": t.route_hash, "hop_ips": ips, "hop_rtts": [h.rtt_ms for h in t.hops],
151 + "asn_path": asndb.asn_path(ips),
152 + }
153 +
154 +
155 +@router.post("/batch")
156 +async def ingest_batch(request: Request) -> dict[str, Any]:
157 + s = get_settings()
158 + raw = await request.body()
159 + if len(raw) > s.ingest_max_body_bytes:
160 + raise HTTPException(413, {"error": "too_large"})
161 + p = await _authenticate(request, raw)
162 + body = raw
163 + if request.headers.get("content-encoding", "").lower() == "gzip":
164 + try:
165 + body = gzip.decompress(raw)
166 + except Exception as exc: # noqa: BLE001
167 + raise HTTPException(400, {"error": "bad_gzip", "detail": str(exc)}) from exc
168 + if len(body) > s.ingest_max_body_bytes * 4:
169 + raise HTTPException(413, {"error": "too_large"})
170 + try:
171 + batch = Batch.model_validate_json(body)
172 + except Exception as exc: # noqa: BLE001
173 + raise HTTPException(422, {"error": "validation", "detail": str(exc)[:500]}) from exc
174 + if batch.probe_id != p["probe_id"]:
175 + raise HTTPException(401, {"error": "unauthorized", "reason": "probe_mismatch"})
176 + if not p.get("enabled", True):
177 + raise HTTPException(403, {"error": "probe_disabled"})
178 + if len(batch.measurements) > s.ingest_max_batch:
179 + raise HTTPException(422, {"error": "validation", "detail": "too many measurements"})
180 +
181 + now = utcnow()
182 + rows = [r for r in (_m_row(batch.probe_id, m) for m in batch.measurements) if r]
183 + trows = [r for r in (_t_row(batch.probe_id, t) for t in batch.traceroutes) if r]
184 + rejected = len(batch.measurements) - len(rows)
185 + if rows:
186 + writer.add("measurements", rows)
187 + if trows:
188 + writer.add("traceroutes", trows)
189 + if batch.health:
190 + h = batch.health
191 + hts = parse_ts(h.ts) or now
192 + writer.add("probe_health", [{
193 + "ts": ch_ts(hts), "probe_id": batch.probe_id, "agent_version": h.agent_version, "uptime_s": h.uptime_s,
194 + "buffered": h.buffered, "spool_bytes": h.spool_bytes, "measurements_total": h.measurements_total,
195 + "errors_total": h.errors_total, "clock_offset_ms": h.clock_offset_ms, "rss_mb": h.rss_mb,
196 + "goroutines": h.goroutines,
197 + }])
198 +
199 + # live state (best-effort)
200 + minute = now.strftime("%Y%m%d%H%M")
201 + try:
202 + r = rds.r()
203 + pipe = r.pipeline()
204 + pipe.set(f"ip:probe:{batch.probe_id}:seen", iso(now).encode(), ex=86400)
205 + if batch.agent_version:
206 + pipe.set(f"ip:probe:{batch.probe_id}:version", batch.agent_version.encode(), ex=86400)
207 + if batch.health:
208 + pipe.set(f"ip:probe:{batch.probe_id}:health", dumps(batch.health.model_dump()), ex=86400)
209 + pipe.incrby(f"ip:ctr:meas:{minute}", len(rows))
210 + pipe.expire(f"ip:ctr:meas:{minute}", 7200)
211 + pipe.incrby(f"ip:ctr:batch:{minute}", 1)
212 + pipe.expire(f"ip:ctr:batch:{minute}", 7200)
213 + if rejected:
214 + pipe.incrby(f"ip:ctr:rej:{minute}", rejected)
215 + pipe.expire(f"ip:ctr:rej:{minute}", 7200)
216 + # latest measurement per (target, probe, kind[, resolver]) for instant views
217 + latest: dict[str, dict[str, Any]] = {}
218 + for row in rows:
219 + field = f"{batch.probe_id}|{row['kind']}|{row['resolver']}"
220 + latest.setdefault(row["target_id"], {})[field] = row
221 + for tid, fields in latest.items():
222 + pipe.hset(f"ip:latest:{tid}", mapping={f: dumps(v) for f, v in fields.items()})
223 + pipe.expire(f"ip:latest:{tid}", 3600)
224 + for row in trows:
225 + pipe.hset(f"ip:route:{row['target_id']}", batch.probe_id, dumps(row))
226 + pipe.expire(f"ip:route:{row['target_id']}", 7 * 86400)
227 + await pipe.execute()
228 + except Exception as exc: # noqa: BLE001
229 + log.warning("redis live update failed: %s", exc)
230 +
231 + # registry touch, at most every 60 s per probe
232 + t0 = _last_touch.get(batch.probe_id, 0.0)
233 + if time.time() - t0 > 60:
234 + _last_touch[batch.probe_id] = time.time()
235 + try:
236 + await touch_probe(
237 + batch.probe_id, version=batch.agent_version or None, ip=_client_ip(request),
238 + capabilities=batch.health.capabilities if batch.health else None,
239 + identity=batch.health.identity.model_dump() if batch.health and batch.health.identity else None,
240 + )
241 + except Exception as exc: # noqa: BLE001
242 + log.warning("probe touch failed: %s", exc)
243 +
244 + sched = await _schedule()
245 + return {
246 + "accepted": len(rows) + len(trows), "rejected": rejected, "config_version": await config_version(),
247 + "server_time": iso(now), "boost": sched.get("boost"),
248 + }
249 +
250 +
251 +@router.get("/agent/latest")
252 +async def agent_latest() -> Response:
253 + s = get_settings()
254 + if s.releases_dir:
255 + try:
256 + with open(f"{s.releases_dir}/latest.json", encoding="utf-8") as fh:
257 + return Response(fh.read(), media_type="application/json")
258 + except FileNotFoundError:
259 + pass
260 + return Response(json.dumps({"version": None, "assets": {}}), media_type="application/json")
added apps/api/src/internetpressure/ingest/writer.py +88 −0
@@ -0,0 +1,88 @@
1 +"""Buffered ClickHouse writer. Rows are appended in-process and flushed every ~2 s or at 5 000 rows.
2 +If ClickHouse is down the buffer grows to a cap (then oldest rows are dropped and counted) — the API keeps
3 +accepting batches so probes do not spool needlessly for a short ClickHouse hiccup."""
4 +
5 +from __future__ import annotations
6 +
7 +import asyncio
8 +import logging
9 +import time
10 +from collections import deque
11 +from typing import Any
12 +
13 +from ..db import ch
14 +
15 +log = logging.getLogger("ip.writer")
16 +
17 +
18 +class Writer:
19 + def __init__(self, *, flush_interval: float = 2.0, max_rows: int = 5000, cap: int = 400_000) -> None:
20 + self.flush_interval = flush_interval
21 + self.max_rows = max_rows
22 + self.cap = cap
23 + self._buffers: dict[str, deque[dict[str, Any]]] = {}
24 + self._task: asyncio.Task | None = None
25 + self._stop = asyncio.Event()
26 + self.inserted_total = 0
27 + self.dropped_total = 0
28 + self.last_error: str | None = None
29 + self.last_flush: float | None = None
30 + self._rate_window: deque[tuple[float, int]] = deque()
31 +
32 + def add(self, table: str, rows: list[dict[str, Any]]) -> None:
33 + buf = self._buffers.setdefault(table, deque())
34 + buf.extend(rows)
35 + while len(buf) > self.cap:
36 + buf.popleft()
37 + self.dropped_total += 1
38 +
39 + def pending(self) -> int:
40 + return sum(len(b) for b in self._buffers.values())
41 +
42 + def inserts_per_s(self) -> float:
43 + now = time.time()
44 + while self._rate_window and self._rate_window[0][0] < now - 60:
45 + self._rate_window.popleft()
46 + return sum(n for _, n in self._rate_window) / 60.0
47 +
48 + async def start(self) -> None:
49 + if self._task is None:
50 + self._stop.clear()
51 + self._task = asyncio.create_task(self._loop(), name="ch-writer")
52 +
53 + async def stop(self) -> None:
54 + self._stop.set()
55 + if self._task:
56 + try:
57 + await asyncio.wait_for(self._task, timeout=10)
58 + except (TimeoutError, asyncio.CancelledError):
59 + self._task.cancel()
60 + self._task = None
61 + await self.flush()
62 +
63 + async def _loop(self) -> None:
64 + while not self._stop.is_set():
65 + try:
66 + await asyncio.wait_for(self._stop.wait(), timeout=self.flush_interval)
67 + except TimeoutError:
68 + pass
69 + await self.flush()
70 +
71 + async def flush(self) -> None:
72 + for table, buf in list(self._buffers.items()):
73 + while buf:
74 + chunk = [buf.popleft() for _ in range(min(len(buf), self.max_rows))]
75 + try:
76 + await ch.insert(table, chunk)
77 + self.inserted_total += len(chunk)
78 + self._rate_window.append((time.time(), len(chunk)))
79 + self.last_flush = time.time()
80 + self.last_error = None
81 + except Exception as exc: # noqa: BLE001
82 + self.last_error = str(exc)[:300]
83 + log.warning("ClickHouse insert failed (%s rows into %s): %s", len(chunk), table, self.last_error)
84 + buf.extendleft(reversed(chunk)) # put back, retry next tick
85 + break
86 +
87 +
88 +writer = Writer()
added apps/api/src/internetpressure/regions.py +88 −0
@@ -0,0 +1,88 @@
1 +"""Region model (data/regions.yaml): region ids, centroids and country → region mapping."""
2 +
3 +from __future__ import annotations
4 +
5 +from dataclasses import dataclass
6 +from functools import lru_cache
7 +from typing import Any
8 +
9 +import yaml
10 +
11 +from .settings import get_settings
12 +
13 +COUNTRY_NAMES: dict[str, str] = {
14 + "CA": "Canada", "US": "United States", "MX": "Mexico", "BR": "Brazil", "AR": "Argentina", "CL": "Chile",
15 + "CO": "Colombia", "PE": "Peru", "GB": "United Kingdom", "IE": "Ireland", "FR": "France", "DE": "Germany",
16 + "NL": "Netherlands", "BE": "Belgium", "LU": "Luxembourg", "CH": "Switzerland", "AT": "Austria", "ES": "Spain",
17 + "PT": "Portugal", "IT": "Italy", "SE": "Sweden", "NO": "Norway", "DK": "Denmark", "FI": "Finland", "IS": "Iceland",
18 + "EE": "Estonia", "LV": "Latvia", "LT": "Lithuania", "PL": "Poland", "CZ": "Czechia", "SK": "Slovakia",
19 + "HU": "Hungary", "RO": "Romania", "BG": "Bulgaria", "UA": "Ukraine", "GR": "Greece", "TR": "Türkiye",
20 + "CY": "Cyprus", "IL": "Israel", "AE": "United Arab Emirates", "SA": "Saudi Arabia", "QA": "Qatar", "EG": "Egypt",
21 + "MA": "Morocco", "ZA": "South Africa", "NG": "Nigeria", "KE": "Kenya", "IN": "India", "PK": "Pakistan",
22 + "BD": "Bangladesh", "SG": "Singapore", "ID": "Indonesia", "MY": "Malaysia", "TH": "Thailand", "VN": "Vietnam",
23 + "PH": "Philippines", "JP": "Japan", "KR": "South Korea", "CN": "China", "HK": "Hong Kong", "TW": "Taiwan",
24 + "AU": "Australia", "NZ": "New Zealand", "RU": "Russia", "BH": "Bahrain",
25 +}
26 +
27 +# Rough country centroids for map labels (lat, lon).
28 +COUNTRY_CENTROIDS: dict[str, tuple[float, float]] = {
29 + "CA": (56.1, -106.3), "US": (39.8, -98.6), "MX": (23.6, -102.5), "BR": (-14.2, -51.9), "AR": (-38.4, -63.6),
30 + "CL": (-35.7, -71.5), "CO": (4.6, -74.3), "PE": (-9.2, -75.0), "GB": (54.0, -2.0), "IE": (53.4, -8.2),
31 + "FR": (46.6, 2.2), "DE": (51.2, 10.4), "NL": (52.1, 5.3), "BE": (50.5, 4.5), "LU": (49.8, 6.1), "CH": (46.8, 8.2),
32 + "AT": (47.5, 14.6), "ES": (40.5, -3.7), "PT": (39.4, -8.2), "IT": (41.9, 12.6), "SE": (60.1, 18.6),
33 + "NO": (60.5, 8.5), "DK": (56.3, 9.5), "FI": (61.9, 25.7), "IS": (64.9, -19.0), "EE": (58.6, 25.0),
34 + "LV": (56.9, 24.6), "LT": (55.2, 23.9), "PL": (51.9, 19.1), "CZ": (49.8, 15.5), "SK": (48.7, 19.7),
35 + "HU": (47.2, 19.5), "RO": (45.9, 25.0), "BG": (42.7, 25.5), "UA": (48.4, 31.2), "GR": (39.1, 21.8),
36 + "TR": (39.0, 35.2), "CY": (35.1, 33.4), "IL": (31.0, 34.9), "AE": (23.4, 53.8), "SA": (23.9, 45.1),
37 + "QA": (25.4, 51.2), "EG": (26.8, 30.8), "MA": (31.8, -7.1), "ZA": (-30.6, 22.9), "NG": (9.1, 8.7),
38 + "KE": (-0.02, 37.9), "IN": (20.6, 79.0), "PK": (30.4, 69.3), "BD": (23.7, 90.4), "SG": (1.35, 103.8),
39 + "ID": (-0.8, 113.9), "MY": (4.2, 102.0), "TH": (15.9, 100.9), "VN": (14.1, 108.3), "PH": (12.9, 121.8),
40 + "JP": (36.2, 138.3), "KR": (35.9, 127.8), "CN": (35.9, 104.2), "HK": (22.3, 114.2), "TW": (23.7, 121.0),
41 + "AU": (-25.3, 133.8), "NZ": (-40.9, 174.9), "RU": (61.5, 105.3), "BH": (26.0, 50.6),
42 +}
43 +
44 +
45 +@dataclass(frozen=True)
46 +class Region:
47 + id: str
48 + name: str
49 + continent: str
50 + lat: float
51 + lon: float
52 +
53 +
54 +@dataclass
55 +class RegionModel:
56 + regions: dict[str, Region]
57 + country_regions: dict[str, str]
58 +
59 + def region_of_country(self, cc: str | None) -> str:
60 + if not cc:
61 + return "global"
62 + return self.country_regions.get(cc.upper(), "global")
63 +
64 + def get(self, region_id: str | None) -> Region | None:
65 + return self.regions.get(region_id or "")
66 +
67 + def public_list(self) -> list[dict[str, Any]]:
68 + return [
69 + {"id": r.id, "name": r.name, "continent": r.continent, "lat": r.lat, "lon": r.lon}
70 + for r in self.regions.values()
71 + ]
72 +
73 +
74 +@lru_cache
75 +def load_regions() -> RegionModel:
76 + with open(get_settings().regions_path, encoding="utf-8") as fh:
77 + raw = yaml.safe_load(fh)
78 + regions = {
79 + rid: Region(rid, spec["name"], spec["continent"], float(spec["lat"]), float(spec["lon"]))
80 + for rid, spec in raw["regions"].items()
81 + }
82 + return RegionModel(regions=regions, country_regions={k.upper(): v for k, v in raw["country_regions"].items()})
83 +
84 +
85 +def country_name(cc: str | None) -> str:
86 + if not cc:
87 + return "Global"
88 + return COUNTRY_NAMES.get(cc.upper(), cc.upper())
added apps/api/src/internetpressure/registry.py +265 −0
@@ -0,0 +1,265 @@
1 +"""Registry access (Postgres): probes, targets, services, ASN metadata, config override, seeding."""
2 +
3 +from __future__ import annotations
4 +
5 +import hashlib
6 +import logging
7 +import secrets
8 +from typing import Any
9 +
10 +import yaml
11 +
12 +from .config import PressureConfig, build_config, load_file_config, merged
13 +from .db import pg, rds
14 +from .regions import load_regions
15 +from .settings import get_settings
16 +from .util import dumps_str, iso, utcnow
17 +
18 +log = logging.getLogger("ip.registry")
19 +
20 +TARGET_COLS = (
21 + "target_id", "name", "hostname", "url", "ip", "port", "category", "provider", "service_id", "country", "region",
22 + "importance", "tier", "checks", "traceroute", "enabled",
23 +)
24 +
25 +
26 +def target_row_to_dict(r: Any) -> dict[str, Any]:
27 + d = {c: r[c] for c in TARGET_COLS}
28 + d["updated_at"] = iso(r["updated_at"]) if "updated_at" in r.keys() else None
29 + return d
30 +
31 +
32 +def probe_row_to_dict(r: Any, *, with_key: bool = False) -> dict[str, Any]:
33 + d = {
34 + "probe_id": r["probe_id"], "name": r["name"], "region": r["region"], "country": r["country"], "city": r["city"],
35 + "provider": r["provider"], "asn": r["asn"], "lat": r["lat"], "lon": r["lon"], "node": r["node"],
36 + "enabled": r["enabled"], "version": r["version"], "capabilities": r["capabilities"] or [],
37 + "identity": r["identity"], "last_seen": iso(r["last_seen_at"]),
38 + "last_ip": str(r["last_ip"]) if r["last_ip"] else None,
39 + }
40 + if with_key:
41 + d["key"] = r["key"]
42 + return d
43 +
44 +
45 +# ── probes ─────────────────────────────────────────────────────────────────────────────────────────────────────────
46 +
47 +async def list_probes(*, enabled_only: bool = False) -> list[dict[str, Any]]:
48 + sql = "SELECT * FROM probes" + (" WHERE enabled" if enabled_only else "") + " ORDER BY probe_id"
49 + return [probe_row_to_dict(r) for r in await pg.fetch(sql)]
50 +
51 +
52 +async def get_probe(probe_id: str, *, with_key: bool = False) -> dict[str, Any] | None:
53 + r = await pg.fetchrow("SELECT * FROM probes WHERE probe_id=$1", probe_id)
54 + return probe_row_to_dict(r, with_key=with_key) if r else None
55 +
56 +
57 +async def upsert_probe(p: dict[str, Any], *, key: str | None = None) -> dict[str, Any]:
58 + key = key or secrets.token_hex(32)
59 + await pg.execute(
60 + """INSERT INTO probes(probe_id,name,region,country,city,provider,asn,lat,lon,node,key)
61 + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
62 + ON CONFLICT (probe_id) DO UPDATE SET name=EXCLUDED.name, region=EXCLUDED.region, country=EXCLUDED.country,
63 + city=EXCLUDED.city, provider=EXCLUDED.provider, asn=EXCLUDED.asn, lat=EXCLUDED.lat, lon=EXCLUDED.lon,
64 + node=EXCLUDED.node, updated_at=now()""",
65 + p["probe_id"], p["name"], p["region"], p.get("country"), p.get("city"), p.get("provider"), p.get("asn"),
66 + p.get("lat"), p.get("lon"), p.get("node"), key,
67 + )
68 + row = await get_probe(p["probe_id"], with_key=True)
69 + assert row
70 + return row
71 +
72 +
73 +async def rotate_probe_key(probe_id: str) -> str:
74 + key = secrets.token_hex(32)
75 + await pg.execute("UPDATE probes SET key=$2, updated_at=now() WHERE probe_id=$1", probe_id, key)
76 + return key
77 +
78 +
79 +async def touch_probe(probe_id: str, *, version: str | None, ip: str | None, capabilities: list[str] | None,
80 + identity: dict | None) -> None:
81 + await pg.execute(
82 + """UPDATE probes SET last_seen_at=now(), version=COALESCE($2,version), last_ip=COALESCE($3::inet,last_ip),
83 + capabilities=COALESCE($4::jsonb,capabilities), identity=COALESCE($5::jsonb,identity) WHERE probe_id=$1""",
84 + probe_id, version, ip, dumps_str(capabilities) if capabilities else None, dumps_str(identity) if identity else None,
85 + )
86 +
87 +
88 +# ── targets ────────────────────────────────────────────────────────────────────────────────────────────────────────
89 +
90 +async def list_targets(*, enabled_only: bool = False) -> list[dict[str, Any]]:
91 + sql = "SELECT * FROM targets" + (" WHERE enabled" if enabled_only else "") + " ORDER BY importance DESC, target_id"
92 + return [target_row_to_dict(r) for r in await pg.fetch(sql)]
93 +
94 +
95 +async def get_target(target_id: str) -> dict[str, Any] | None:
96 + r = await pg.fetchrow("SELECT * FROM targets WHERE target_id=$1", target_id)
97 + return target_row_to_dict(r) if r else None
98 +
99 +
100 +async def upsert_target(t: dict[str, Any]) -> dict[str, Any]:
101 + regions = load_regions()
102 + region = t.get("region") or regions.region_of_country(t.get("country"))
103 + await pg.execute(
104 + """INSERT INTO targets(target_id,name,hostname,url,ip,port,category,provider,service_id,country,region,importance,tier,
105 + checks,traceroute,enabled)
106 + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16)
107 + ON CONFLICT (target_id) DO UPDATE SET name=EXCLUDED.name, hostname=EXCLUDED.hostname, url=EXCLUDED.url,
108 + ip=EXCLUDED.ip, port=EXCLUDED.port, category=EXCLUDED.category, provider=EXCLUDED.provider,
109 + service_id=EXCLUDED.service_id, country=EXCLUDED.country, region=EXCLUDED.region,
110 + importance=EXCLUDED.importance, tier=EXCLUDED.tier, checks=EXCLUDED.checks, traceroute=EXCLUDED.traceroute,
111 + enabled=EXCLUDED.enabled, updated_at=now()""",
112 + t["target_id"], t["name"], t["hostname"], t.get("url") or f"https://{t['hostname']}/", t.get("ip"),
113 + int(t.get("port") or 443), t["category"], t.get("provider"), t.get("service_id"), t.get("country"), region,
114 + int(t.get("importance") or 3), int(t.get("tier") or 2), dumps_str(t.get("checks") or ["http", "dns", "ping"]),
115 + bool(t.get("traceroute", False)), bool(t.get("enabled", True)),
116 + )
117 + row = await get_target(t["target_id"])
118 + assert row
119 + return row
120 +
121 +
122 +async def delete_target(target_id: str) -> bool:
123 + res = await pg.execute("DELETE FROM targets WHERE target_id=$1", target_id)
124 + return res.endswith("1")
125 +
126 +
127 +# ── services / asn ─────────────────────────────────────────────────────────────────────────────────────────────────
128 +
129 +async def list_services() -> list[dict[str, Any]]:
130 + rows = await pg.fetch("SELECT * FROM services ORDER BY importance DESC, slug")
131 + return [dict(r) for r in rows]
132 +
133 +
134 +async def get_service(slug: str) -> dict[str, Any] | None:
135 + r = await pg.fetchrow("SELECT * FROM services WHERE slug=$1", slug)
136 + return dict(r) if r else None
137 +
138 +
139 +async def upsert_service(s: dict[str, Any]) -> None:
140 + await pg.execute(
141 + """INSERT INTO services(slug,name,category,asns,importance,status) VALUES($1,$2,$3,$4,$5,$6::jsonb)
142 + ON CONFLICT (slug) DO UPDATE SET name=EXCLUDED.name, category=EXCLUDED.category, asns=EXCLUDED.asns,
143 + importance=EXCLUDED.importance, status=EXCLUDED.status, updated_at=now()""",
144 + s["slug"], s["name"], s.get("category"), [int(a) for a in (s.get("asns") or [])], int(s.get("importance") or 3),
145 + dumps_str(s["status"]) if s.get("status") else None,
146 + )
147 +
148 +
149 +async def asn_names(asns: list[int]) -> dict[int, str]:
150 + if not asns:
151 + return {}
152 + rows = await pg.fetch("SELECT asn, name FROM asn_meta WHERE asn = ANY($1::int[])", list({int(a) for a in asns}))
153 + return {r["asn"]: r["name"] for r in rows}
154 +
155 +
156 +async def upsert_asn_meta(rows: list[tuple[int, str | None, str | None]]) -> None:
157 + if not rows:
158 + return
159 + await pg.executemany(
160 + """INSERT INTO asn_meta(asn,name,country) VALUES($1,$2,$3)
161 + ON CONFLICT (asn) DO UPDATE SET name=COALESCE(EXCLUDED.name, asn_meta.name),
162 + country=COALESCE(EXCLUDED.country, asn_meta.country), updated_at=now()""",
163 + rows,
164 + )
165 +
166 +
167 +# ── config override ────────────────────────────────────────────────────────────────────────────────────────────────
168 +
169 +async def load_config() -> PressureConfig:
170 + """File config merged with the Postgres override (if any). Never raises on a bad override: falls back to file."""
171 + file_cfg = load_file_config()
172 + try:
173 + row = await pg.fetchrow("SELECT value, updated_at FROM config WHERE key='pressure'")
174 + except Exception as exc: # noqa: BLE001
175 + log.warning("config override unavailable: %s", exc)
176 + return file_cfg
177 + if not row:
178 + return file_cfg
179 + try:
180 + return build_config(merged(file_cfg.raw, row["value"]), source="postgres", updated_at=iso(row["updated_at"]))
181 + except Exception as exc: # noqa: BLE001
182 + log.error("invalid config override, using file: %s", exc)
183 + return file_cfg
184 +
185 +
186 +async def save_config_override(value: dict[str, Any], actor: str = "admin") -> PressureConfig:
187 + file_cfg = load_file_config()
188 + cfg = build_config(merged(file_cfg.raw, value), source="postgres") # raises ConfigError on invalid
189 + await pg.execute(
190 + """INSERT INTO config(key,value,updated_by) VALUES('pressure',$1::jsonb,$2)
191 + ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now(), updated_by=EXCLUDED.updated_by""",
192 + dumps_str(value), actor,
193 + )
194 + await pg.execute("INSERT INTO audit_log(actor,action,detail) VALUES($1,'config.update',$2::jsonb)", actor,
195 + dumps_str(value))
196 + await bump_config_version()
197 + return cfg
198 +
199 +
200 +# ── probe config version ───────────────────────────────────────────────────────────────────────────────────────────
201 +
202 +async def compute_config_version() -> str:
203 + row = await pg.fetchrow("SELECT count(*) AS n, max(updated_at) AS m FROM targets")
204 + boost = await rds.get_json("ip:boost") or {}
205 + cfgrow = await pg.fetchrow("SELECT updated_at FROM config WHERE key='pressure'")
206 + h = hashlib.sha1(
207 + f"{row['n']}|{row['m']}|{dumps_str(boost)}|{cfgrow['updated_at'] if cfgrow else ''}".encode()
208 + ).hexdigest()[:8]
209 + return f"{utcnow().strftime('%Y%m%dT%H%M%SZ')}-{h}"
210 +
211 +
212 +async def config_version() -> str:
213 + v = await rds.r().get("ip:config_version")
214 + if v:
215 + return v.decode()
216 + return await bump_config_version()
217 +
218 +
219 +async def bump_config_version() -> str:
220 + v = await compute_config_version()
221 + await rds.r().set("ip:config_version", v)
222 + return v
223 +
224 +
225 +# ── seeding ────────────────────────────────────────────────────────────────────────────────────────────────────────
226 +
227 +async def seed(*, targets: bool = True, services: bool = True, probes: bool = True) -> dict[str, int]:
228 + s = get_settings()
229 + counts = {"services": 0, "targets": 0, "probes": 0}
230 + regions = load_regions()
231 + if services:
232 + with open(s.services_path, encoding="utf-8") as fh:
233 + for svc in yaml.safe_load(fh)["services"]:
234 + await upsert_service(svc)
235 + counts["services"] += 1
236 + if targets:
237 + with open(s.targets_path, encoding="utf-8") as fh:
238 + doc = yaml.safe_load(fh)
239 + defaults = doc.get("defaults") or {}
240 + for t in doc["targets"]:
241 + row = {
242 + "target_id": t["id"], "name": t["name"], "hostname": t["host"], "url": t.get("url"), "ip": t.get("ip"),
243 + "port": t.get("port", 443), "category": t["cat"], "provider": t.get("provider"), "service_id": t.get("svc"),
244 + "country": t.get("cc"), "region": regions.region_of_country(t.get("cc")),
245 + "importance": t.get("imp", defaults.get("imp", 3)), "tier": t.get("tier", defaults.get("tier", 2)),
246 + "checks": t.get("checks", defaults.get("checks", ["http", "dns", "ping"])),
247 + "traceroute": t.get("tr", defaults.get("tr", False)), "enabled": t.get("enabled", True),
248 + }
249 + if row["service_id"]:
250 + exists = await pg.fetchval("SELECT 1 FROM services WHERE slug=$1", row["service_id"])
251 + if not exists:
252 + await upsert_service({"slug": row["service_id"], "name": row["service_id"].title(), "category": row["category"]})
253 + await upsert_target(row)
254 + counts["targets"] += 1
255 + if probes:
256 + with open(s.probes_path, encoding="utf-8") as fh:
257 + for p in yaml.safe_load(fh)["probes"]:
258 + existing = await get_probe(p["probe_id"], with_key=True)
259 + await upsert_probe(p, key=existing["key"] if existing else None)
260 + counts["probes"] += 1
261 + try:
262 + await bump_config_version()
263 + except Exception as exc: # noqa: BLE001
264 + log.warning("config version bump skipped: %s", exc)
265 + return counts
added apps/api/src/internetpressure/settings.py +63 −0
@@ -0,0 +1,63 @@
1 +"""Runtime settings (environment variables). Every variable is documented in deploy/.env.example."""
2 +
3 +from __future__ import annotations
4 +
5 +from functools import lru_cache
6 +from pathlib import Path
7 +
8 +from pydantic import Field
9 +from pydantic_settings import BaseSettings, SettingsConfigDict
10 +
11 +REPO_ROOT = Path(__file__).resolve().parents[4]
12 +
13 +
14 +class Settings(BaseSettings):
15 + model_config = SettingsConfigDict(env_prefix="IP_", env_file=(REPO_ROOT / ".env", ".env"), extra="ignore")
16 +
17 + env: str = "dev"
18 + log_level: str = "info"
19 + site_url: str = "https://www.internetpressure.io"
20 +
21 + # stores
22 + pg_dsn: str = "postgresql://ip:ip@127.0.0.1:5435/ip"
23 + ch_url: str = "http://127.0.0.1:8124"
24 + ch_db: str = "ip"
25 + ch_user: str = "ip"
26 + ch_password: str = "ip"
27 + redis_url: str = "redis://127.0.0.1:6380/0"
28 +
29 + # api
30 + api_host: str = "0.0.0.0"
31 + api_port: int = 8352
32 + admin_token: str = Field(default="", description="X-IP-Admin-Token; empty disables the admin API")
33 + trust_proxy: bool = True
34 + public_rate_limit_per_min: int = 120
35 + sse_max_per_ip: int = 4
36 + releases_dir: str = "" # optional directory with probe binaries + latest.json for self-update
37 +
38 + # paths
39 + config_path: Path = REPO_ROOT / "packages" / "config" / "pressure.yaml"
40 + regions_path: Path = REPO_ROOT / "data" / "regions.yaml"
41 + targets_path: Path = REPO_ROOT / "data" / "targets" / "targets.yaml"
42 + services_path: Path = REPO_ROOT / "data" / "seed" / "services.yaml"
43 + probes_path: Path = REPO_ROOT / "data" / "seed" / "probes.yaml"
44 + data_dir: Path = REPO_ROOT / "data" / "asn"
45 +
46 + # ingestion
47 + ingest_max_skew_s: int = 300
48 + ingest_max_batch: int = 2000
49 + ingest_max_body_bytes: int = 4 * 1024 * 1024
50 +
51 + # bgp
52 + ris_live_url: str = "wss://ris-live.ripe.net/v1/ws/?client=internetpressure-io"
53 + ris_collectors: str = "" # comma-separated subset (empty = all collectors)
54 + bgp_store_raw: bool = True
55 + bgp_raw_sample: int = 25 # store 1 out of N announcements raw (withdrawals always stored); the firehose is ~10k prefixes/s
56 +
57 + # engine
58 + engine_cycle_s: int = 0 # 0 = use pressure.yaml engine.cycle_seconds
59 +
60 +
61 +@lru_cache
62 +def get_settings() -> Settings:
63 + return Settings()
added apps/api/src/internetpressure/util.py +99 −0
@@ -0,0 +1,99 @@
1 +"""Small shared helpers (time, json, math)."""
2 +
3 +from __future__ import annotations
4 +
5 +import math
6 +from datetime import UTC, datetime, timedelta
7 +from typing import Any
8 +
9 +import orjson
10 +
11 +
12 +def utcnow() -> datetime:
13 + return datetime.now(UTC)
14 +
15 +
16 +def iso(dt: datetime | None) -> str | None:
17 + if dt is None:
18 + return None
19 + if dt.tzinfo is None:
20 + dt = dt.replace(tzinfo=UTC)
21 + return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.") + f"{dt.microsecond // 1000:03d}Z"
22 +
23 +
24 +def parse_ts(value: Any) -> datetime | None:
25 + if value is None:
26 + return None
27 + if isinstance(value, datetime):
28 + return value if value.tzinfo else value.replace(tzinfo=UTC)
29 + s = str(value).strip()
30 + if not s:
31 + return None
32 + if s.endswith("Z"):
33 + s = s[:-1] + "+00:00"
34 + try:
35 + dt = datetime.fromisoformat(s)
36 + except ValueError:
37 + try:
38 + dt = datetime.fromisoformat(s.replace(" ", "T"))
39 + except ValueError:
40 + return None
41 + return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
42 +
43 +
44 +def ch_ts(dt: datetime) -> str:
45 + """ClickHouse DateTime64(3) literal (UTC, no zone suffix)."""
46 + if dt.tzinfo is None:
47 + dt = dt.replace(tzinfo=UTC)
48 + return dt.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S.") + f"{dt.microsecond // 1000:03d}"
49 +
50 +
51 +def dumps(obj: Any) -> bytes:
52 + return orjson.dumps(obj, default=_default)
53 +
54 +
55 +def dumps_str(obj: Any) -> str:
56 + return dumps(obj).decode()
57 +
58 +
59 +def loads(data: bytes | str) -> Any:
60 + return orjson.loads(data)
61 +
62 +
63 +def _default(o: Any) -> Any:
64 + if isinstance(o, datetime):
65 + return iso(o)
66 + if isinstance(o, timedelta):
67 + return o.total_seconds()
68 + if isinstance(o, set):
69 + return sorted(o)
70 + if hasattr(o, "__dict__"):
71 + return o.__dict__
72 + raise TypeError(str(type(o)))
73 +
74 +
75 +def r1(x: float | None) -> float | None:
76 + return None if x is None or (isinstance(x, float) and math.isnan(x)) else round(float(x), 1)
77 +
78 +
79 +def r2(x: float | None) -> float | None:
80 + return None if x is None or (isinstance(x, float) and math.isnan(x)) else round(float(x), 2)
81 +
82 +
83 +def r3(x: float | None) -> float | None:
84 + return None if x is None or (isinstance(x, float) and math.isnan(x)) else round(float(x), 3)
85 +
86 +
87 +def clamp(x: float, lo: float, hi: float) -> float:
88 + return lo if x < lo else hi if x > hi else x
89 +
90 +
91 +def bearing_to_direction(lat1: float, lon1: float, lat2: float, lon2: float) -> str:
92 + """Compass direction from point 1 to point 2 (8-wind)."""
93 + phi1, phi2 = math.radians(lat1), math.radians(lat2)
94 + dl = math.radians(lon2 - lon1)
95 + x = math.sin(dl) * math.cos(phi2)
96 + y = math.cos(phi1) * math.sin(phi2) - math.sin(phi1) * math.cos(phi2) * math.cos(dl)
97 + brng = (math.degrees(math.atan2(x, y)) + 360) % 360
98 + names = ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"]
99 + return names[int((brng + 22.5) // 45) % 8]
added apps/api/tests/test_scoring.py +165 −0
@@ -0,0 +1,165 @@
1 +import math
2 +
3 +import pytest
4 +
5 +from internetpressure.config import ConfigError, build_config, load_file_config
6 +from internetpressure.engine import scoring as S
7 +
8 +
9 +def test_robust_z_basic_and_clipping():
10 + assert S.robust_z(30, 20, 2) == 5.0
11 + assert S.robust_z(200, 20, 2) == 8.0 # clipped high
12 + assert S.robust_z(0, 20, 2) == -3.0 # clipped low
13 + assert S.robust_z(None, 20, 2) is None
14 + assert S.robust_z(20, None, 2) is None
15 +
16 +
17 +def test_robust_z_mad_floor_prevents_explosion():
18 + # ultra-tight baseline (MAD 0.01 ms on a 20 ms median): floor = max(1.0, 0.05*20=1.0) → z uses 1.0
19 + assert S.robust_z(25, 20, 0.01) == 5.0
20 + # relative floor on large medians
21 + assert S.robust_z(1100, 1000, 1) == pytest.approx(2.0) # floor 50 ms
22 +
23 +
24 +def test_mad_from_iqr():
25 + assert S.mad_from_iqr(10, 20) == pytest.approx(7.413)
26 + assert S.mad_from_iqr(None, 20) is None
27 +
28 +
29 +def test_z_to_stress_ramp():
30 + assert S.z_to_stress(None) == 0
31 + assert S.z_to_stress(0.5) == 0
32 + assert S.z_to_stress(1.0) == 0
33 + assert S.z_to_stress(3.5) == pytest.approx(0.5)
34 + assert S.z_to_stress(6) == 1
35 + assert S.z_to_stress(8) == 1
36 +
37 +
38 +def test_rate_stress():
39 + assert S.rate_stress(0.0, 0.0) == 0
40 + assert S.rate_stress(0.25, 0.0) == 1.0
41 + assert S.rate_stress(0.125, 0.0) == pytest.approx(0.5)
42 + # a noisy target with a 10 % baseline failure rate needs 30 % excess for full stress
43 + assert S.rate_stress(0.25, 0.10) == pytest.approx(0.5)
44 + assert S.rate_stress(0.05, 0.10) == 0
45 +
46 +
47 +def test_ratio_stress():
48 + assert S.ratio_stress(100, 100) == 0
49 + assert S.ratio_stress(150, 100) == 0
50 + assert S.ratio_stress(375, 100) == pytest.approx(0.5)
51 + assert S.ratio_stress(1000, 100) == 1
52 + assert S.ratio_stress(10, 0) == 0
53 +
54 +
55 +def test_saturate_bounds_and_shape():
56 + assert S.saturate(0) == 0
57 + assert S.saturate(1) == pytest.approx(100)
58 + mid = S.saturate(0.5)
59 + assert 55 < mid < 75 # concave: half stress is well above 50
60 + assert S.saturate(0.1) < S.saturate(0.2) < S.saturate(0.5)
61 + assert S.saturate(0.5, k=0) == 50
62 +
63 +
64 +def test_coverage_factor():
65 + assert S.coverage_factor(0, 24) == 0
66 + assert S.coverage_factor(2, 24) == 0 # below 20 %
67 + assert S.coverage_factor(24, 24) == 1
68 + assert 0 < S.coverage_factor(12, 24) < 1
69 + assert S.coverage_factor(5, 0) == 1
70 +
71 +
72 +def test_weighted_stress_and_breadth():
73 + pairs = [
74 + S.PairStress("a", 1.0, 2.0),
75 + S.PairStress("b", 0.0, 1.0),
76 + S.PairStress("c", 0.5, 1.0),
77 + S.PairStress("d", 0.9, 0.0), # zero weight ignored
78 + ]
79 + stress, tw, n = S.weighted_stress(pairs)
80 + assert n == 3 and tw == 4.0
81 + assert stress == pytest.approx((2.0 + 0 + 0.5) / 4.0)
82 + assert S.breadth(pairs, threshold=0.4) == pytest.approx(3.0 / 4.0)
83 + assert S.weighted_stress([]) == (0.0, 0.0, 0)
84 +
85 +
86 +def test_component_score_renormalises_missing_signals():
87 + w = {"a": 0.5, "b": 0.3, "c": 0.2}
88 + assert S.component_score({}, w) == 0
89 + full = S.component_score({"a": 1, "b": 1, "c": 1}, w)
90 + assert full == pytest.approx(100)
91 + # only "a" observed at full stress → still 100 (renormalised), not 50
92 + assert S.component_score({"a": 1}, w) == pytest.approx(100)
93 + assert S.component_score({"a": 0, "b": 0, "c": 0}, w) == 0
94 +
95 +
96 +def test_global_score_weights_from_config():
97 + cfg = load_file_config()
98 + comps = {c: 50.0 for c in cfg.weights}
99 + score, contrib = S.global_score(comps, cfg.weights)
100 + assert score == pytest.approx(50.0)
101 + assert sum(contrib.values()) == pytest.approx(50.0)
102 + # missing routing → renormalised over the rest
103 + comps["routing"] = None
104 + score2, contrib2 = S.global_score(comps, cfg.weights)
105 + assert score2 == pytest.approx(50.0)
106 + assert "routing" not in contrib2
107 +
108 +
109 +def test_global_score_example_from_spec():
110 + weights = {"routing": .25, "latency": .20, "dns": .15, "availability": .15, "http_tls": .10, "path": .10,
111 + "corroboration": .05}
112 + comps = {"routing": 57, "latency": 44, "dns": 19, "availability": 37, "http_tls": 28, "path": 51, "corroboration": 0}
113 + score, _ = S.global_score(comps, weights)
114 + assert score == pytest.approx(57 * .25 + 44 * .2 + 19 * .15 + 37 * .15 + 28 * .1 + 51 * .1)
115 +
116 +
117 +def test_confidence_monotone():
118 + low = S.confidence_score(probes=1, probe_regions=1, signals_agreeing=1, samples=2, min_samples=24)
119 + high = S.confidence_score(probes=8, probe_regions=4, signals_agreeing=3, samples=100, min_samples=24,
120 + duration_s=1200, bgp_corroborated=True, external_corroborated=True)
121 + assert 0 < low < high <= 1.0
122 +
123 +
124 +def test_velocity_and_acceleration():
125 + pts = [(t * 60.0, 20 + t * 0.5) for t in range(0, 30)] # +0.5/min = +30/h
126 + v, a = S.velocity(pts)
127 + assert v == pytest.approx(30.0, rel=1e-3)
128 + assert a == pytest.approx(0.0, abs=1e-6)
129 + pts2 = [(t * 60.0, 20 + 0.02 * t * t) for t in range(0, 30)] # accelerating
130 + v2, a2 = S.velocity(pts2)
131 + assert v2 > 0 and a2 > 0
132 + assert S.velocity(pts[:3]) == (None, None)
133 +
134 +
135 +def test_volatility_and_trend():
136 + assert S.volatility([1, 1, 1]) == 0
137 + assert S.volatility([1, 2]) is None
138 + assert S.volatility([10, 20, 30]) == pytest.approx(10.0)
139 + assert S.trend_word(0.4) == "stable"
140 + assert S.trend_word(5) == "rising"
141 + assert S.trend_word(-5) == "falling"
142 + assert S.trend_word(None) == "stable"
143 +
144 +
145 +def test_config_validation_rejects_bad_weights():
146 + cfg = load_file_config()
147 + raw = dict(cfg.raw)
148 + raw["pressure_weights"] = {**cfg.weights, "routing": 0.5}
149 + with pytest.raises(ConfigError):
150 + build_config(raw)
151 + raw["pressure_weights"] = {k: v for k, v in cfg.weights.items() if k != "path"}
152 + with pytest.raises(ConfigError):
153 + build_config(raw)
154 +
155 +
156 +def test_levels_from_config():
157 + cfg = load_file_config()
158 + assert cfg.level_for(5)[0] == "calm"
159 + assert cfg.level_for(10)[0] == "calm"
160 + assert cfg.level_for(10.1)[0] == "normal"
161 + assert cfg.level_for(42.7)[0] == "stressed"
162 + assert cfg.level_for(99)[0] == "extreme"
163 + assert cfg.level_for(None)[0] == "unknown"
164 + assert not math.isnan(cfg.importance_weight(5))
165 + assert cfg.importance_weight(5) > cfg.importance_weight(1)
added apps/web/.gitignore +9 −0
@@ -0,0 +1,9 @@
1 +node_modules
2 +.next
3 +out
4 +*.tsbuildinfo
5 +next-env.d.ts
6 +qa/screens
7 +public/maplibre
8 +.env
9 +.env.local
added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1 +<!-- BEGIN:nextjs-agent-rules -->
2 +
3 +# This is NOT the Next.js you know
4 +
5 +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6 +
7 +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8 +
9 +<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1 +@AGENTS.md
added apps/web/eslint.config.mjs +8 −0
@@ -0,0 +1,8 @@
1 +import nextVitals from 'eslint-config-next/core-web-vitals';
2 +import nextTs from 'eslint-config-next/typescript';
3 +
4 +export default [
5 + ...nextVitals,
6 + ...nextTs,
7 + { ignores: ['.next/**', 'node_modules/**', 'mock/**', 'qa/**'] },
8 +];
added apps/web/mock/server.mjs +734 −0
@@ -0,0 +1,734 @@
1 +#!/usr/bin/env node
2 +/**
3 + * InternetPressure.io — DEV-ONLY mock of the public + admin API (docs/API.md), plain Node, no dependencies.
4 + *
5 + * node mock/server.mjs # http://127.0.0.1:8352
6 + * MOCK_DEGRADED=1 node mock/… # simulates internal_status="degraded" + stale score (spec §57 banner)
7 + * MOCK_ADMIN_TOKEN=… # admin token (default "dev-admin-token")
8 + *
9 + * Every value is a plausible, self-consistent fixture derived from a seeded generator so pages look real while the
10 + * FastAPI backend is being written. This file never ships in the Docker image and is never used in production.
11 + * Fixtures drift very slightly on each engine "cycle" (10 s) so the live layer can be exercised; the SSE stream only
12 + * emits on those cycles — never between them — mirroring the real contract ("nothing without a computation").
13 + */
14 +import http from 'node:http';
15 +import { URL } from 'node:url';
16 +
17 +const PORT = Number(process.env.PORT ?? 8352);
18 +const ADMIN_TOKEN = process.env.MOCK_ADMIN_TOKEN ?? 'dev-admin-token';
19 +const DEGRADED = process.env.MOCK_DEGRADED === '1';
20 +const START = Date.now();
21 +
22 +// ---------------------------------------------------------------- deterministic pseudo-random
23 +let seed = 20260912;
24 +const rnd = () => {
25 + seed = (seed * 1664525 + 1013904223) % 4294967296;
26 + return seed / 4294967296;
27 +};
28 +const r = (a, b, d = 1) => Number((a + rnd() * (b - a)).toFixed(d));
29 +const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
30 +const iso = (t) => new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
31 +const now = () => Date.now();
32 +const minutesAgo = (m) => iso(now() - m * 60_000);
33 +const clamp = (v, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, v));
34 +
35 +const LEVELS = [
36 + { max: 10, id: 'calm', label: 'Exceptionally calm' },
37 + { max: 25, id: 'normal', label: 'Normal' },
38 + { max: 40, id: 'elevated', label: 'Elevated' },
39 + { max: 55, id: 'stressed', label: 'Stressed' },
40 + { max: 70, id: 'high', label: 'Highly stressed' },
41 + { max: 85, id: 'severe', label: 'Severe disruption' },
42 + { max: 100, id: 'extreme', label: 'Extreme Internet event' },
43 +];
44 +const levelOf = (p) => LEVELS.find((l) => p <= l.max) ?? LEVELS[LEVELS.length - 1];
45 +const trendOf = (d) => (d > 1.5 ? 'rising' : d < -1.5 ? 'falling' : 'stable');
46 +const WEIGHTS = { routing: 0.25, latency: 0.2, dns: 0.15, availability: 0.15, http_tls: 0.1, path: 0.1, corroboration: 0.05 };
47 +const COMP_LABEL = { routing: 'Routing', latency: 'Latency', dns: 'DNS', availability: 'Availability', http_tls: 'HTTP/TLS', path: 'Path', corroboration: 'Corroboration' };
48 +
49 +// ---------------------------------------------------------------- regions (data/regions.yaml)
50 +const REGION_DEFS = [
51 + ['na-east', 'North America East', 'North America', 43, -76],
52 + ['na-central', 'North America Central', 'North America', 41, -95],
53 + ['na-west', 'North America West', 'North America', 40, -120],
54 + ['latam', 'Latin America', 'South America', -15, -55],
55 + ['eu-west', 'Western Europe', 'Europe', 49, 3],
56 + ['eu-north', 'Northern Europe', 'Europe', 60, 18],
57 + ['eu-east', 'Eastern Europe', 'Europe', 50, 22],
58 + ['eu-east-med', 'Eastern Mediterranean', 'Europe', 38, 30],
59 + ['mena', 'Middle East & North Africa', 'Asia', 26, 45],
60 + ['africa', 'Sub-Saharan Africa', 'Africa', -5, 22],
61 + ['asia-south', 'South Asia', 'Asia', 22, 78],
62 + ['asia-se', 'Southeast Asia', 'Asia', 5, 108],
63 + ['asia-east', 'East Asia', 'Asia', 35, 125],
64 + ['oceania', 'Oceania', 'Oceania', -30, 140],
65 +];
66 +const REGION_PRESSURE = {
67 + 'na-east': 51.3, 'na-central': 27.4, 'na-west': 22.8, latam: 19.5, 'eu-west': 31.2, 'eu-north': 14.9, 'eu-east': 21.7,
68 + 'eu-east-med': 33.6, mena: 24.1, africa: 18.3, 'asia-south': 26.9, 'asia-se': 16.4, 'asia-east': 12.7, oceania: 9.8,
69 +};
70 +const REGION_DELTA = {
71 + 'na-east': 12.1, 'na-central': 3.4, 'na-west': 0.8, latam: -1.2, 'eu-west': 4.9, 'eu-north': -0.4, 'eu-east': 1.1,
72 + 'eu-east-med': 6.2, mena: 0.3, africa: -2.6, 'asia-south': 2.2, 'asia-se': -0.9, 'asia-east': -1.8, oceania: 0.1,
73 +};
74 +const PROBE_REGIONS = new Set(['na-east', 'eu-west', 'eu-east-med', 'asia-se', 'oceania']);
75 +
76 +// ---------------------------------------------------------------- probes
77 +const PROBES = [
78 + ['ca-qc-01', 'Québec City (Bell)', 'na-east', 'CA', 'Québec', 'Bell Canada', 577, 46.8, -71.2],
79 + ['ca-qc-02', 'Québec City (Vidéotron)', 'na-east', 'CA', 'Québec', 'Vidéotron', 5769, 46.8, -71.3],
80 + ['ca-bhs-01', 'Beauharnois (OVHcloud)', 'na-east', 'CA', 'Beauharnois', 'OVHcloud', 16276, 45.3, -73.9],
81 + ['fr-gra-01', 'Gravelines (OVHcloud)', 'eu-west', 'FR', 'Gravelines', 'OVHcloud', 16276, 51.0, 2.1],
82 + ['ie-dub-01', 'Dublin (MacStadium)', 'eu-west', 'IE', 'Dublin', 'MacStadium', 41064, 53.3, -6.3],
83 + ['tr-ist-01', 'Istanbul (Macly)', 'eu-east-med', 'TR', 'Istanbul', 'Macly', 34984, 41.0, 29.0],
84 + ['sg-sin-01', 'Singapore (Vultr)', 'asia-se', 'SG', 'Singapore', 'Vultr', 20473, 1.3, 103.8],
85 + ['au-syd-01', 'Sydney (Vultr)', 'oceania', 'AU', 'Sydney', 'Vultr', 20473, -33.9, 151.2],
86 +].map(([probe_id, name, region, country, city, provider, asn, lat, lon], i) => ({
87 + probe_id, name, region, country, city, provider, asn, lat, lon,
88 + status: 'online', last_seen: minutesAgo(0), version: i === 6 ? '0.1.0-rc3' : '0.1.0',
89 + measurements_1h: 4200 + i * 137, uptime_24h: Number((0.991 + i * 0.001).toFixed(3)),
90 + clock_offset_ms: [-14, 3, -2, 8, -21, 5, 11, -6][i],
91 + capabilities: ['http', 'dns', 'ping', 'traceroute'],
92 +}));
93 +
94 +// ---------------------------------------------------------------- ASNs & services & targets
95 +const ASNS = [
96 + [13335, 'Cloudflare, Inc.', 'US', 5, 18.2, 1200], [15169, 'Google LLC', 'US', 5, 11.4, 980], [16509, 'Amazon.com, Inc.', 'US', 5, 24.6, 3100],
97 + [8075, 'Microsoft Corporation', 'US', 5, 20.1, 1450], [20940, 'Akamai International B.V.', 'NL', 5, 14.0, 610], [54113, 'Fastly, Inc.', 'US', 4, 22.7, 210],
98 + [32934, 'Meta Platforms, Inc.', 'US', 4, 12.3, 320], [2906, 'Netflix Streaming Services', 'US', 3, 9.7, 95], [36459, 'GitHub, Inc.', 'US', 4, 27.9, 40],
99 + [16276, 'OVH SAS', 'FR', 4, 21.5, 890], [577, 'Bell Canada', 'CA', 4, 44.8, 1720], [5769, 'Videotron Ltee', 'CA', 3, 38.2, 260],
100 + [6453, 'TATA Communications (America) Inc', 'US', 5, 58.4, 7400], [3356, 'Lumen (Level 3)', 'US', 5, 31.6, 12400], [1299, 'Arelion (Telia Carrier)', 'SE', 5, 19.9, 6900],
101 + [174, 'Cogent Communications', 'US', 5, 26.2, 9800], [2914, 'NTT America', 'US', 5, 17.3, 5600], [3320, 'Deutsche Telekom AG', 'DE', 4, 13.8, 2300],
102 + [12876, 'Scaleway S.a.s.', 'FR', 3, 15.2, 140], [19551, 'Incapsula (Imperva)', 'US', 3, 16.6, 75], [14061, 'DigitalOcean, LLC', 'US', 3, 18.9, 330],
103 + [20473, 'The Constant Company (Vultr)', 'US', 3, 14.4, 410], [9121, 'Turk Telekom', 'TR', 3, 36.1, 1100], [7922, 'Comcast Cable', 'US', 4, 15.7, 3700],
104 + [4134, 'China Telecom', 'CN', 4, 23.3, 8100], [9498, 'Bharti Airtel', 'IN', 3, 28.6, 2900], [4766, 'Korea Telecom', 'KR', 3, 10.9, 1400],
105 + [4837, 'China Unicom', 'CN', 3, 21.8, 4300], [7545, 'TPG Telecom', 'AU', 3, 12.6, 620], [1221, 'Telstra', 'AU', 3, 8.9, 940],
106 +].map(([asn, name, country, importance, pressure, prefixes]) => ({ asn, name, country, importance, pressure, prefixes_observed: prefixes }));
107 +const asnById = Object.fromEntries(ASNS.map((a) => [a.asn, a]));
108 +
109 +const SERVICES = [
110 + ['cloudflare', 'Cloudflare', 'cdn', 13335, 'status.cloudflare.com', 'none'], ['aws', 'Amazon Web Services', 'cloud', 16509, 'health.aws.amazon.com', 'minor'],
111 + ['google', 'Google', 'search', 15169, 'status.cloud.google.com', 'none'], ['azure', 'Microsoft Azure', 'cloud', 8075, 'azure.status.microsoft', 'none'],
112 + ['github', 'GitHub', 'developer', 36459, 'www.githubstatus.com', 'none'], ['akamai', 'Akamai', 'cdn', 20940, null, null],
113 + ['fastly', 'Fastly', 'cdn', 54113, 'status.fastly.com', 'none'], ['netflix', 'Netflix', 'streaming', 2906, null, null],
114 + ['meta', 'Meta (Facebook, Instagram, WhatsApp)', 'social', 32934, 'metastatus.com', 'none'], ['openai', 'OpenAI', 'ai', 13335, 'status.openai.com', 'none'],
115 + ['anthropic', 'Anthropic', 'ai', 13335, 'status.anthropic.com', 'none'], ['apple', 'Apple', 'commerce', 714, 'www.apple.com/support/systemstatus', 'none'],
116 + ['stripe', 'Stripe', 'finance', 13335, 'status.stripe.com', 'none'], ['shopify', 'Shopify', 'commerce', 13335, 'www.shopifystatus.com', 'none'],
117 + ['zoom', 'Zoom', 'messaging', 16509, 'status.zoom.us', 'none'], ['slack', 'Slack', 'messaging', 16509, 'slack-status.com', 'none'],
118 + ['discord', 'Discord', 'messaging', 13335, 'discordstatus.com', 'none'], ['reddit', 'Reddit', 'social', 54113, 'www.redditstatus.com', 'none'],
119 + ['wikipedia', 'Wikimedia', 'news', 14907, 'www.wikimediastatus.net', 'none'], ['spotify', 'Spotify', 'streaming', 15169, null, null],
120 + ['canada-gc', 'Government of Canada', 'government', 577, null, null], ['quad9', 'Quad9', 'dns', 19281, null, null],
121 + ['ovhcloud', 'OVHcloud', 'cloud', 16276, 'www.status-ovhcloud.com', 'none'], ['digitalocean', 'DigitalOcean', 'cloud', 14061, 'status.digitalocean.com', 'none'],
122 +];
123 +const SERVICE_PRESSURE = { cloudflare: 12.0, aws: 41.7, google: 9.2, azure: 17.8, github: 33.4, akamai: 11.1, fastly: 21.3, netflix: 8.4, meta: 10.6, openai: 15.9, anthropic: 14.2, apple: 7.9, stripe: 9.8, shopify: 13.5, zoom: 16.0, slack: 29.1, discord: 12.8, reddit: 19.4, wikipedia: 6.3, spotify: 8.8, 'canada-gc': 35.2, quad9: 5.1, ovhcloud: 20.7, digitalocean: 18.6 };
124 +
125 +const HOSTS = {
126 + cloudflare: ['www.cloudflare.com', 'api.cloudflare.com', 'one.one.one.one', 'cdnjs.cloudflare.com', 'dash.cloudflare.com', 'workers.dev'],
127 + aws: ['aws.amazon.com', 's3.amazonaws.com', 'ec2.us-east-1.amazonaws.com', 's3.ca-central-1.amazonaws.com', 'dynamodb.us-east-1.amazonaws.com', 'lambda.eu-west-1.amazonaws.com', 'sts.amazonaws.com', 'cloudfront.net', 'ec2.us-west-2.amazonaws.com', 'ec2.eu-west-3.amazonaws.com'],
128 + google: ['www.google.com', 'www.googleapis.com', 'dns.google', 'storage.googleapis.com', 'www.youtube.com', 'accounts.google.com', 'fonts.googleapis.com', 'mail.google.com', 'maps.googleapis.com', 'play.google.com'],
129 + azure: ['azure.microsoft.com', 'login.microsoftonline.com', 'management.azure.com', 'blob.core.windows.net', 'outlook.office365.com', 'graph.microsoft.com', 'teams.microsoft.com', 'www.microsoft.com', 'update.microsoft.com', 'xbox.com'],
130 + github: ['github.com', 'api.github.com', 'raw.githubusercontent.com', 'codeload.github.com', 'ghcr.io', 'objects.githubusercontent.com', 'pages.github.com', 'copilot.github.com'],
131 + akamai: ['www.akamai.com', 'akamaihd.net', 'akamaized.net', 'edgekey.net', 'akamaitechnologies.com', 'edgesuite.net'],
132 + fastly: ['www.fastly.com', 'api.fastly.com', 'fastly.net', 'global.ssl.fastly.net', 'pypi.org', 'files.pythonhosted.org'],
133 + netflix: ['www.netflix.com', 'api-global.netflix.com', 'nflxvideo.net', 'assets.nflxext.com'],
134 + meta: ['www.facebook.com', 'www.instagram.com', 'web.whatsapp.com', 'graph.facebook.com', 'www.threads.net', 'static.xx.fbcdn.net', 'www.messenger.com', 'developers.facebook.com'],
135 + openai: ['api.openai.com', 'chat.openai.com', 'platform.openai.com', 'cdn.openai.com', 'auth.openai.com', 'chatgpt.com'],
136 + anthropic: ['api.anthropic.com', 'claude.ai', 'www.anthropic.com', 'console.anthropic.com', 'docs.anthropic.com', 'status.anthropic.com'],
137 + apple: ['www.apple.com', 'www.icloud.com', 'apps.apple.com', 'swcdn.apple.com', 'gsa.apple.com', 'developer.apple.com', 'idmsa.apple.com', 'mesu.apple.com', 'ocsp.apple.com', 'push.apple.com'],
138 + stripe: ['api.stripe.com', 'js.stripe.com', 'dashboard.stripe.com', 'checkout.stripe.com', 'files.stripe.com', 'hooks.stripe.com'],
139 + shopify: ['www.shopify.com', 'cdn.shopify.com', 'shop.app', 'admin.shopify.com', 'myshopify.com', 'shopifycloud.com'],
140 + zoom: ['zoom.us', 'api.zoom.us', 'us02web.zoom.us', 'zoomgov.com', 'us04web.zoom.us', 'zoom.com'],
141 + slack: ['slack.com', 'api.slack.com', 'app.slack.com', 'files.slack.com', 'edgeapi.slack.com', 'wss-primary.slack.com', 'a.slack-edge.com', 'status.slack.com'],
142 + discord: ['discord.com', 'gateway.discord.gg', 'cdn.discordapp.com', 'media.discordapp.net', 'discordapp.com', 'discord.gg'],
143 + reddit: ['www.reddit.com', 'oauth.reddit.com', 'i.redd.it', 'old.reddit.com', 'styles.redditmedia.com', 'gateway.reddit.com'],
144 + wikipedia: ['en.wikipedia.org', 'fr.wikipedia.org', 'upload.wikimedia.org', 'www.wikidata.org', 'commons.wikimedia.org', 'api.wikimedia.org', 'de.wikipedia.org', 'ja.wikipedia.org', 'es.wikipedia.org', 'meta.wikimedia.org'],
145 + spotify: ['open.spotify.com', 'api.spotify.com', 'accounts.spotify.com', 'i.scdn.co', 'spclient.wg.spotify.com', 'www.spotify.com'],
146 + 'canada-gc': ['www.canada.ca', 'www.cra-arc.gc.ca', 'www.quebec.ca', 'www.servicecanada.gc.ca', 'www.tpsgc-pwgsc.gc.ca', 'www.ic.gc.ca', 'www.weather.gc.ca', 'www.statcan.gc.ca', 'www.elections.ca', 'www.parl.ca'],
147 + quad9: ['dns.quad9.net', 'www.quad9.net', 'dns9.quad9.net', 'dns10.quad9.net', 'dns11.quad9.net', 'on.quad9.net'],
148 + ovhcloud: ['www.ovhcloud.com', 'api.ovh.com', 'www.ovh.com', 'ca.api.ovh.com', 'eu.api.ovh.com', 'kimsufi.com', 'soyoustart.com', 'www.ovhtelecom.fr', 'mail.ovh.net', 'docs.ovh.com', 'help.ovhcloud.com', 'partners.ovhcloud.com'],
149 + digitalocean: ['www.digitalocean.com', 'api.digitalocean.com', 'cloud.digitalocean.com', 'digitaloceanspaces.com', 'nyc3.digitaloceanspaces.com', 'docs.digitalocean.com', 'registry.digitalocean.com', 'droplets.digitalocean.com', 'community.digitalocean.com', 'marketplace.digitalocean.com'],
150 +};
151 +const COUNTRY_OF_SERVICE = { cloudflare: 'US', aws: 'US', google: 'US', azure: 'US', github: 'US', akamai: 'NL', fastly: 'US', netflix: 'US', meta: 'US', openai: 'US', anthropic: 'US', apple: 'US', stripe: 'US', shopify: 'CA', zoom: 'US', slack: 'US', discord: 'US', reddit: 'US', wikipedia: 'US', spotify: 'SE', 'canada-gc': 'CA', quad9: 'CH', ovhcloud: 'FR', digitalocean: 'US' };
152 +const COUNTRY_REGION = { CA: 'na-east', US: 'na-east', FR: 'eu-west', IE: 'eu-west', GB: 'eu-west', DE: 'eu-west', NL: 'eu-west', CH: 'eu-west', SE: 'eu-north', PL: 'eu-east', TR: 'eu-east-med', AE: 'mena', ZA: 'africa', IN: 'asia-south', SG: 'asia-se', JP: 'asia-east', KR: 'asia-east', HK: 'asia-east', AU: 'oceania', BR: 'latam', MX: 'latam' };
153 +const ANCHOR = ['ca-central-1', 'us-east-1', 'eu-west-3', 'eu-west-1', 'ap-southeast-1', 'ap-southeast-2', 'sa-east-1', 'ap-south-1', 'eu-north-1', 'ap-northeast-1', 'me-central-1', 'af-south-1', 'eu-central-1', 'ap-east-1', 'eu-west-2', 'ap-northeast-2', 'eu-south-1', 'mx-central-1'];
154 +const ANCHOR_CC = ['CA', 'US', 'FR', 'IE', 'SG', 'AU', 'BR', 'IN', 'SE', 'JP', 'AE', 'ZA', 'DE', 'HK', 'GB', 'KR', 'CH', 'MX'];
155 +
156 +const TARGETS = [];
157 +let anchorIdx = 0;
158 +for (const [slug, name, category, asn] of SERVICES) {
159 + HOSTS[slug].forEach((hostname, i) => {
160 + const cc = i === 0 ? COUNTRY_OF_SERVICE[slug] : ANCHOR_CC[anchorIdx++ % ANCHOR_CC.length];
161 + const region = COUNTRY_REGION[cc];
162 + const regional = REGION_PRESSURE[region] ?? 20;
163 + const base = SERVICE_PRESSURE[slug];
164 + const pressure = Number(clamp(base * 0.6 + regional * 0.3 + r(-6, 6)).toFixed(1));
165 + TARGETS.push({
166 + target_id: `${slug}-${hostname.split('.').slice(0, -1).join('-').replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || slug}`.slice(0, 48),
167 + name: i === 0 ? name : `${name} · ${hostname}`, hostname, category, provider: name, service_id: slug, country: cc, region,
168 + importance: i === 0 ? 5 : i < 3 ? 4 : 3, tier: i === 0 ? 1 : i < 3 ? 2 : 3, asn,
169 + pressure, ok_ratio_1h: Number(clamp(1 - pressure / 400 - (pressure > 40 ? r(0.01, 0.05, 3) : 0), 0, 1).toFixed(4)),
170 + ttfb_ms_median_1h: r(40, 260, 0),
171 + });
172 + });
173 +}
174 +// dedupe ids
175 +const seenIds = new Set();
176 +for (const t of TARGETS) { let id = t.target_id, n = 2; while (seenIds.has(id)) id = `${t.target_id}-${n++}`; t.target_id = id; seenIds.add(id); }
177 +const targetById = Object.fromEntries(TARGETS.map((t) => [t.target_id, t]));
178 +const TARGETS_TOTAL = TARGETS.length; // 212 with the lists above
179 +
180 +// ---------------------------------------------------------------- countries with coverage
181 +const COUNTRY_NAMES = { CA: 'Canada', US: 'United States', FR: 'France', IE: 'Ireland', GB: 'United Kingdom', DE: 'Germany', NL: 'Netherlands', CH: 'Switzerland', SE: 'Sweden', TR: 'Türkiye', AE: 'United Arab Emirates', ZA: 'South Africa', IN: 'India', SG: 'Singapore', JP: 'Japan', KR: 'South Korea', HK: 'Hong Kong', AU: 'Australia', BR: 'Brazil', MX: 'Mexico' };
182 +const COUNTRY_CENTROID = { CA: [56.1, -106.3], US: [39.8, -98.6], FR: [46.6, 2.2], IE: [53.4, -8.2], GB: [55.4, -3.4], DE: [51.2, 10.4], NL: [52.1, 5.3], CH: [46.8, 8.2], SE: [60.1, 18.6], TR: [39.0, 35.2], AE: [23.4, 53.8], ZA: [-30.6, 22.9], IN: [20.6, 79.0], SG: [1.35, 103.8], JP: [36.2, 138.3], KR: [35.9, 127.8], HK: [22.4, 114.1], AU: [-25.3, 133.8], BR: [-14.2, -51.9], MX: [23.6, -102.6] };
183 +const COUNTRY_PRESSURE = { CA: 34.0, US: 47.8, FR: 29.6, IE: 27.1, GB: 30.4, DE: 28.7, NL: 26.9, CH: 24.2, SE: 14.9, TR: 33.6, AE: 24.1, ZA: 18.3, IN: 26.9, SG: 16.4, JP: 12.7, KR: 11.9, HK: 15.3, AU: 9.8, BR: 19.5, MX: 21.0 };
184 +const COUNTRY_DELTA = { CA: 2.0, US: 11.4, FR: 4.1, IE: 3.9, GB: 4.4, DE: 3.2, NL: 2.8, CH: 1.9, SE: -0.4, TR: 6.2, AE: 0.3, ZA: -2.6, IN: 2.2, SG: -0.9, JP: -1.8, KR: -1.1, HK: -0.6, AU: 0.1, BR: -1.2, MX: 0.6 };
185 +
186 +function regionComponents(id, p) {
187 + const routing = PROBE_REGIONS.has(id) || ['na-central', 'na-west', 'eu-north'].includes(id) ? Number(clamp(p * 1.1 + r(-8, 8)).toFixed(1)) : null;
188 + return {
189 + routing, latency: Number(clamp(p * 1.2 + r(-5, 5)).toFixed(1)), dns: Number(clamp(p * 0.35 + r(-3, 3)).toFixed(1)),
190 + availability: Number(clamp(p * 0.7 + r(-4, 4)).toFixed(1)), http_tls: Number(clamp(p * 0.45 + r(-4, 4)).toFixed(1)), path: Number(clamp(p * 1.05 + r(-6, 6)).toFixed(1)),
191 + };
192 +}
193 +const REGION_COMPONENTS = Object.fromEntries(REGION_DEFS.map(([id]) => [id, regionComponents(id, REGION_PRESSURE[id])]));
194 +const COUNTRY_COMPONENTS = Object.fromEntries(Object.keys(COUNTRY_PRESSURE).map((cc) => [cc, regionComponents(COUNTRY_REGION[cc], COUNTRY_PRESSURE[cc])]));
195 +
196 +// ---------------------------------------------------------------- live engine state (drifts per cycle)
197 +let cycle = 0;
198 +const GLOBAL_BASE = 42.7;
199 +function drift() { return DEGRADED ? 0 : Math.sin(cycle / 7) * 0.9 + Math.sin(cycle / 3.1) * 0.3; }
200 +function currentGlobal() { return Number((GLOBAL_BASE + drift()).toFixed(1)); }
201 +function engineTs() { return DEGRADED ? iso(START - 14 * 60_000) : iso(now() - (now() % 10_000)); }
202 +const internalStatus = () => (DEGRADED ? 'degraded' : 'ok');
203 +
204 +// 24 h history shape: diurnal baseline around 28–34, dip overnight, spike in the last ~45 minutes.
205 +function pressureAt(tsMs, base = GLOBAL_BASE) {
206 + const ageMin = (now() - tsMs) / 60_000;
207 + const hour = new Date(tsMs).getUTCHours() + new Date(tsMs).getUTCMinutes() / 60;
208 + const diurnal = 30 + 4 * Math.sin(((hour - 6) / 24) * 2 * Math.PI);
209 + const noise = 1.6 * Math.sin(tsMs / 1_730_000) + 0.9 * Math.sin(tsMs / 610_000) + 0.4 * Math.sin(tsMs / 97_000);
210 + const spike = ageMin < 48 ? (base - diurnal) * (1 - ageMin / 48) ** 0.7 : 0;
211 + const bump = ageMin > 600 && ageMin < 700 ? 18 * Math.sin(((ageMin - 600) / 100) * Math.PI) : 0; // the resolved DNS incident 10–11.6 h ago
212 + return Number(clamp(diurnal + noise + spike + bump, 3, 97).toFixed(1));
213 +}
214 +function scaleFor(scopeType, scopeId) {
215 + if (scopeType === 'global' || !scopeId) return 1;
216 + if (scopeType === 'region') return (REGION_PRESSURE[scopeId] ?? 25) / GLOBAL_BASE;
217 + if (scopeType === 'country') return (COUNTRY_PRESSURE[scopeId.toUpperCase()] ?? 25) / GLOBAL_BASE;
218 + if (scopeType === 'asn') return (asnById[Number(scopeId)]?.pressure ?? 20) / GLOBAL_BASE;
219 + if (scopeType === 'service') return (SERVICE_PRESSURE[scopeId] ?? 15) / GLOBAL_BASE;
220 + return 1;
221 +}
222 +const STEP = { '1h': 10, '6h': 60, '24h': 60, '7d': 300, '30d': 3600, '1y': 86400 };
223 +const RANGE_S = { '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000, '1y': 31536000 };
224 +function history(scopeType, scopeId, range) {
225 + const step = STEP[range] ?? 60;
226 + const span = RANGE_S[range] ?? 86400;
227 + const scale = scaleFor(scopeType, scopeId);
228 + const end = now() - (now() % (step * 1000));
229 + const points = [];
230 + for (let t = end - span * 1000; t <= end; t += step * 1000) {
231 + const p = Number(clamp(pressureAt(t) * scale).toFixed(1));
232 + points.push({ ts: iso(t), pressure: p, components: {
233 + routing: Number(clamp(p * 1.3 - 2).toFixed(1)), latency: Number(clamp(p * 1.05).toFixed(1)), dns: Number(clamp(p * 0.42).toFixed(1)),
234 + availability: Number(clamp(p * 0.8).toFixed(1)), http_tls: Number(clamp(p * 0.6).toFixed(1)), path: Number(clamp(p * 1.15).toFixed(1)), corroboration: Number(clamp(p * 0.1).toFixed(1)) },
235 + confidence: 0.85 });
236 + }
237 + const ps = points.map((x) => x.pressure);
238 + const maxI = ps.indexOf(Math.max(...ps));
239 + return { scope_type: scopeType, scope_id: scopeId ?? null, range, step_seconds: step, points,
240 + summary: { min: Math.min(...ps), max: Math.max(...ps), avg: Number((ps.reduce((a, b) => a + b, 0) / ps.length).toFixed(1)), max_ts: points[maxI].ts } };
241 +}
242 +const history24 = (scopeType, scopeId) => { const h = history(scopeType, scopeId, '24h'); return { step_seconds: 60, points: h.points.map((p) => ({ ts: p.ts, pressure: p.pressure })) }; };
243 +
244 +// ---------------------------------------------------------------- global pressure
245 +function componentsGlobal(p) {
246 + const scores = { routing: 57.1, latency: 44.3, dns: 19.2, availability: 37.4, http_tls: 28.0, path: 51.2, corroboration: 22.0 };
247 + const deltas = { routing: 11.0, latency: 8.4, dns: -0.6, availability: 4.1, http_tls: 2.2, path: 9.7, corroboration: 6.0 };
248 + const conf = { routing: 0.9, latency: 0.88, dns: 0.82, availability: 0.85, http_tls: 0.8, path: 0.76, corroboration: 0.6 };
249 + const drivers = {
250 + routing: [{ label: 'BGP withdrawals/s 4.8× baseline', points: 9.1, scope_type: 'bgp', scope_id: 'withdrawals' }, { label: 'Origin ASN changes 3× baseline', points: 2.9, scope_type: 'bgp', scope_id: 'origin_changes' }, { label: 'Collector disagreement (rrc00 vs rrc11)', points: 2.3, scope_type: 'bgp', scope_id: 'collectors' }],
251 + latency: [{ label: 'TTFB +43 % from North America East', points: 5.8, scope_type: 'region', scope_id: 'na-east' }, { label: 'Packet loss 3.1 % on transatlantic pairs', points: 2.6, scope_type: 'region', scope_id: 'na-east' }],
252 + dns: [{ label: 'SERVFAIL rate 1.2× baseline (resolver 9.9.9.9)', points: 1.4, scope_type: 'signal', scope_id: 'dns_fail_rate' }],
253 + availability: [{ label: '4 targets failing from ≥2 probe regions', points: 3.9, scope_type: 'signal', scope_id: 'target_down_corroborated' }, { label: 'AWS us-east-1 endpoints failure rate 2.1×', points: 1.7, scope_type: 'service', scope_id: 'aws' }],
254 + http_tls: [{ label: 'TLS handshake failures 1.6× baseline', points: 1.6, scope_type: 'signal', scope_id: 'tls_fail_rate' }, { label: 'HTTP 5xx 1.3× (github, slack)', points: 1.2, scope_type: 'signal', scope_id: 'http_5xx_rate' }],
255 + path: [{ label: 'Route fingerprints changed on 62 % of NA→EU paths', points: 3.4, scope_type: 'signal', scope_id: 'route_change_rate' }, { label: 'Paths crossing AS6453 +12 ms', points: 1.7, scope_type: 'asn', scope_id: '6453' }],
256 + corroboration: [{ label: 'AWS reports a minor incident (health.aws.amazon.com)', points: 1.1, scope_type: 'service', scope_id: 'aws' }],
257 + };
258 + const d = drift();
259 + return Object.keys(WEIGHTS).map((id) => {
260 + const score = Number(clamp(scores[id] + d * (id === 'routing' ? 1.6 : id === 'latency' ? 1.1 : 0.4)).toFixed(1));
261 + return { id, label: COMP_LABEL[id], score, weight: WEIGHTS[id], contribution: Number((score * WEIGHTS[id]).toFixed(1)), trend: trendOf(deltas[id]), delta_1h: deltas[id], confidence: conf[id], drivers: drivers[id] };
262 + });
263 +}
264 +function sparkline1h() {
265 + const pts = [];
266 + const end = now() - (now() % 60_000);
267 + for (let i = 59; i >= 0; i--) pts.push(i === 23 ? null : pressureAt(end - i * 60_000));
268 + pts[59] = currentGlobal();
269 + return pts;
270 +}
271 +function globalPressure() {
272 + const p = currentGlobal();
273 + const lvl = levelOf(p);
274 + const comps = componentsGlobal(p);
275 + return {
276 + ts: engineTs(), pressure: p, level: lvl.id, level_label: lvl.label,
277 + delta_1h: 6.3, delta_24h: -2.1, velocity_per_h: 7.2, acceleration_per_h2: 3.1, volatility_1h: 2.4, trend: 'rising',
278 + confidence: 0.86, stale: DEGRADED, internal_status: internalStatus(),
279 + coverage: { probes_active: DEGRADED ? 1 : 8, probes_total: 8, probe_regions: DEGRADED ? 1 : 5, targets: TARGETS_TOTAL, measurements_5m: 41200, bgp_collectors: 12, baseline_days: 6.2 },
280 + components: comps,
281 + explain: [
282 + { text: '+14.3 points from elevated BGP route churn', points: comps[0].contribution, component: 'routing', scope_type: 'global', scope_id: null },
283 + { text: '+11.0 from North America East packet loss and latency', points: 11.0, component: 'latency', scope_type: 'region', scope_id: 'na-east' },
284 + { text: '+5.1 from route changes on North America → Western Europe paths', points: 5.1, component: 'path', scope_type: 'region', scope_id: 'eu-west' },
285 + { text: '+3.9 from 4 corroborated target failures (AWS us-east-1)', points: 3.9, component: 'availability', scope_type: 'service', scope_id: 'aws' },
286 + { text: '+1.6 from TLS handshake failures', points: 1.6, component: 'http_tls', scope_type: 'global', scope_id: null },
287 + { text: '−3.1 because Western Europe latency remains within baseline', points: -3.1, component: 'latency', scope_type: 'region', scope_id: 'eu-west' },
288 + { text: '−2.4 because East Asia and Oceania are calm', points: -2.4, component: 'availability', scope_type: 'region', scope_id: 'asia-east' },
289 + ],
290 + sparkline_1h: sparkline1h(),
291 + };
292 +}
293 +
294 +// ---------------------------------------------------------------- regions / countries
295 +function regionObj(id) {
296 + const [, name, continent, lat, lon] = REGION_DEFS.find((d) => d[0] === id);
297 + const p = Number(clamp(REGION_PRESSURE[id] + drift() * (id === 'na-east' ? 1.4 : 0.3)).toFixed(1));
298 + const lvl = levelOf(p);
299 + const probes = PROBES.filter((x) => x.region === id).length;
300 + const targets = TARGETS.filter((t) => t.region === id).length;
301 + return { id, name, continent, lat, lon, pressure: p, level: lvl.id, level_label: lvl.label, delta_1h: REGION_DELTA[id], trend: trendOf(REGION_DELTA[id]), confidence: probes ? 0.8 : 0.62,
302 + components: REGION_COMPONENTS[id], probes, targets, incidents: id === 'na-east' ? 1 : id === 'eu-west' ? 1 : 0, coverage_ok: probes > 0 || targets >= 8, role: probes && targets ? 'both' : probes ? 'probe' : 'target' };
303 +}
304 +const regionsList = () => REGION_DEFS.map(([id]) => regionObj(id));
305 +function countryObj(cc) {
306 + const [lat, lon] = COUNTRY_CENTROID[cc];
307 + const p = Number(clamp(COUNTRY_PRESSURE[cc] + drift() * 0.4).toFixed(1));
308 + const lvl = levelOf(p);
309 + const probes = PROBES.filter((x) => x.country === cc).length;
310 + const targets = TARGETS.filter((t) => t.country === cc).length;
311 + return { cc, name: COUNTRY_NAMES[cc], region: COUNTRY_REGION[cc], lat, lon, pressure: p, level: lvl.id, level_label: lvl.label, delta_1h: COUNTRY_DELTA[cc], trend: trendOf(COUNTRY_DELTA[cc]),
312 + components: COUNTRY_COMPONENTS[cc], probes, targets, role: probes && targets ? 'both' : probes ? 'probe' : 'target', coverage_ok: probes > 0 || targets >= 4 };
313 +}
314 +const countriesList = () => Object.keys(COUNTRY_PRESSURE).map(countryObj);
315 +
316 +// ---------------------------------------------------------------- incidents
317 +const INCIDENT_START = START - 21 * 60_000;
318 +function incidentNaEast() {
319 + const p = Number(clamp(71.2 + drift() * 1.5).toFixed(1));
320 + return {
321 + event_id: 'evt_01J7QX3M8K2ZP9V4N6B1C0D5EF', slug: '2026-09-12-north-america-east-latency-anomaly', type: 'regional_latency',
322 + title: 'North America East latency anomaly', summary: 'Elevated latency and packet loss observed from 3 probes toward 41 targets.', status: 'active',
323 + scope_type: 'region', scope_id: 'na-east', scope_label: 'North America East',
324 + started_at: iso(INCIDENT_START), updated_at: engineTs(), ended_at: null, duration_s: Math.round((now() - INCIDENT_START) / 1000),
325 + peak_pressure: 76.0, current_pressure: p, confidence: 0.93, affected_probes: 3, affected_targets: 41, affected_asns: [577, 16276, 6453], affected_services: ['aws', 'github', 'slack'],
326 + hypotheses: [
327 + { text: 'Possible upstream transit issue on AS6453 (TATA) transatlantic segment', confidence: 0.6, evidence: ['Route fingerprints changed on 62 % of paths', 'Latency rose on paths crossing AS6453', 'BGP withdrawals 4.8× baseline from rrc00/rrc11'] },
328 + { text: 'Congestion at a Montréal/New York interconnection', confidence: 0.3, evidence: ['Loss concentrated on ca-qc-01 and ca-bhs-01', 'No change on ie-dub-01 → NA paths'] },
329 + ],
330 + };
331 +}
332 +function incidentRouting() {
333 + return {
334 + event_id: 'evt_01J7QX7A2B3C4D5E6F7G8H9J0K', slug: '2026-09-12-as6453-routing-instability', type: 'routing_instability',
335 + title: 'Routing instability around AS6453', summary: 'Withdrawal rate 4.8× baseline and 62 % of transatlantic route fingerprints changed.', status: 'developing',
336 + scope_type: 'asn', scope_id: '6453', scope_label: 'AS6453 TATA Communications',
337 + started_at: iso(START - 16 * 60_000), updated_at: engineTs(), ended_at: null, duration_s: Math.round((now() - (START - 16 * 60_000)) / 1000),
338 + peak_pressure: 58.4, current_pressure: Number(clamp(58.4 + drift()).toFixed(1)), confidence: 0.71, affected_probes: 4, affected_targets: 63, affected_asns: [6453, 3356, 1299], affected_services: ['aws', 'fastly'],
339 + hypotheses: [{ text: 'Transit reconfiguration or link failure inside AS6453', confidence: 0.55, evidence: ['Origin changes stable (no hijack pattern)', 'Withdrawals concentrated on prefixes with AS6453 in path'] }],
340 + };
341 +}
342 +const RESOLVED = [
343 + { event_id: 'evt_01J7Q1A0B1C2D3E4F5G6H7J8K9', slug: '2026-09-12-dns-resolver-disruption-eu-west', type: 'dns_disruption', title: 'DNS resolver disruption in Western Europe', summary: 'SERVFAIL rate 6× baseline on two public resolvers from fr-gra-01 and ie-dub-01.', status: 'resolved', scope_type: 'region', scope_id: 'eu-west', scope_label: 'Western Europe', started_at: iso(START - 690 * 60_000), updated_at: iso(START - 590 * 60_000), ended_at: iso(START - 590 * 60_000), duration_s: 6000, peak_pressure: 48.9, current_pressure: 14.2, confidence: 0.84, affected_probes: 2, affected_targets: 27, affected_asns: [19281, 15169], affected_services: ['quad9', 'google'], hypotheses: [{ text: 'Resolver-side outage at Quad9 European POPs', confidence: 0.7, evidence: ['Disagreement between 9.9.9.9 and 1.1.1.1 answers', 'Authoritative servers answered normally'] }] },
344 + { event_id: 'evt_01J7P9Z8Y7X6W5V4U3T2S1R0Q9', slug: '2026-09-11-github-service-degradation', type: 'service_degradation', title: 'GitHub service degradation', summary: 'HTTP 5xx on api.github.com from 6 probes for 34 minutes; vendor confirmed 18 minutes later.', status: 'resolved', scope_type: 'service', scope_id: 'github', scope_label: 'GitHub', started_at: iso(START - 31 * 3600_000), updated_at: iso(START - 30.4 * 3600_000), ended_at: iso(START - 30.4 * 3600_000), duration_s: 2040, peak_pressure: 44.1, current_pressure: 33.4, confidence: 0.91, affected_probes: 6, affected_targets: 5, affected_asns: [36459], affected_services: ['github'], hypotheses: [{ text: 'Provider-side API degradation', confidence: 0.85, evidence: ['5xx from all probe regions simultaneously', 'No routing or latency anomaly toward AS36459'] }] },
345 + { event_id: 'evt_01J7N5M4L3K2J1H0G9F8E7D6C5', slug: '2026-09-09-turkiye-availability-loss', type: 'availability_loss', title: 'Availability loss observed from Türkiye', summary: '38 % of targets unreachable from tr-ist-01 for 52 minutes; probe not excluded (local uplink healthy).', status: 'resolved', scope_type: 'country', scope_id: 'TR', scope_label: 'Türkiye', started_at: iso(START - 70 * 3600_000), updated_at: iso(START - 69.1 * 3600_000), ended_at: iso(START - 69.1 * 3600_000), duration_s: 3120, peak_pressure: 66.8, current_pressure: 33.6, confidence: 0.77, affected_probes: 1, affected_targets: 81, affected_asns: [9121], affected_services: ['meta', 'discord', 'wikipedia'], hypotheses: [{ text: 'National-scale filtering or upstream failure at AS9121', confidence: 0.5, evidence: ['Failures limited to a single probe region', 'TCP resets rather than timeouts on 71 % of failures'] }] },
346 + { event_id: 'evt_01J7M2B3C4D5E6F7G8H9J0K1L2', slug: '2026-09-08-global-pressure-spike', type: 'global_pressure', title: 'Global pressure spike', summary: 'Global index reached 61.4 for 27 minutes driven by simultaneous routing churn and CDN degradation.', status: 'resolved', scope_type: 'global', scope_id: null, scope_label: 'Global', started_at: iso(START - 97 * 3600_000), updated_at: iso(START - 96.5 * 3600_000), ended_at: iso(START - 96.5 * 3600_000), duration_s: 1620, peak_pressure: 61.4, current_pressure: 42.7, confidence: 0.88, affected_probes: 8, affected_targets: 112, affected_asns: [3356, 54113, 13335], affected_services: ['fastly', 'cloudflare', 'reddit'], hypotheses: [{ text: 'Large transit event at AS3356 propagating to CDN edges', confidence: 0.6, evidence: ['Withdrawals 7× baseline', 'Path changes on 48 % of sampled traceroutes'] }] },
347 + { event_id: 'evt_01J7K8A9B0C1D2E3F4G5H6J7K8', slug: '2026-09-06-path-instability-asia-se', type: 'path_instability', title: 'Path instability toward Southeast Asia', summary: 'Route fingerprints changed on 71 % of paths to ap-southeast-1 anchored targets.', status: 'resolved', scope_type: 'region', scope_id: 'asia-se', scope_label: 'Southeast Asia', started_at: iso(START - 140 * 3600_000), updated_at: iso(START - 138 * 3600_000), ended_at: iso(START - 138 * 3600_000), duration_s: 7200, peak_pressure: 39.7, current_pressure: 16.4, confidence: 0.66, affected_probes: 5, affected_targets: 22, affected_asns: [2914, 4134], affected_services: ['aws'], hypotheses: [{ text: 'Submarine cable maintenance rerouting via NTT', confidence: 0.4, evidence: ['Hop count +3 on affected paths', 'Latency shift +38 ms'] }] },
348 +];
349 +const activeIncidents = () => [incidentNaEast(), incidentRouting()];
350 +const allIncidents = () => [...activeIncidents(), ...RESOLVED];
351 +function incidentDetail(inc) {
352 + const start = new Date(inc.started_at).getTime();
353 + const end = inc.ended_at ? new Date(inc.ended_at).getTime() : now();
354 + const step = Math.max(60, Math.round((end - start + 30 * 60_000) / 180 / 60) * 60);
355 + const points = [];
356 + for (let t = start - 30 * 60_000; t <= end; t += step * 1000) {
357 + const frac = clamp((t - start) / Math.max(1, end - start), 0, 1);
358 + const shape = t < start ? 0 : inc.ended_at ? Math.sin(frac * Math.PI) : 1 - Math.exp(-frac * 4);
359 + points.push({ ts: iso(t), pressure: Number(clamp(inc.current_pressure * 0.3 + (inc.peak_pressure - inc.current_pressure * 0.3) * shape + r(-1.2, 1.2)).toFixed(1)), global_pressure: Number(clamp(pressureAt(t)).toFixed(1)) });
360 + }
361 + const scope = inc.scope_type;
362 + return {
363 + ...inc,
364 + timeline: [
365 + { ts: iso(start), status: 'detected', pressure: 46.1, note: 'Component score crossed detect threshold (45) on 2 consecutive cycles' },
366 + { ts: iso(start + 2 * 60_000), status: 'developing', pressure: 52.3, note: 'Corroborated by 3 probes; BGP withdrawals rising' },
367 + { ts: iso(start + 6 * 60_000), status: 'active', pressure: inc.peak_pressure, note: 'Peak pressure reached' },
368 + ...(inc.status === 'resolved' ? [{ ts: iso(end - 10 * 60_000), status: 'recovering', pressure: 24.0, note: 'Below recover threshold (30)' }, { ts: iso(end), status: 'resolved', pressure: inc.current_pressure, note: '10 minutes continuously below threshold' }] : []),
369 + ],
370 + evidence: [
371 + { signal_id: 'ttfb_z', label: 'HTTP time-to-first-byte vs baseline', scope_type: scope, scope_id: inc.scope_id, current: 161.0, baseline: 110.0, robust_z: 5.6, samples: 412, ts: inc.updated_at },
372 + { signal_id: 'loss', label: 'Packet loss', scope_type: scope, scope_id: inc.scope_id, current: 3.1, baseline: 0.2, robust_z: 4.9, samples: 380, ts: inc.updated_at },
373 + { signal_id: 'route_change_rate', label: 'Route fingerprint changes vs baseline churn', scope_type: scope, scope_id: inc.scope_id, current: 0.62, baseline: 0.08, robust_z: 6.8, samples: 96, ts: inc.updated_at },
374 + { signal_id: 'bgp_withdrawals_z', label: 'BGP withdrawals/s vs baseline', scope_type: 'bgp', scope_id: 'withdrawals', current: 52.4, baseline: 11.0, robust_z: 4.76, samples: 60, ts: inc.updated_at },
375 + { signal_id: 'rtt_z', label: 'ICMP round-trip time vs baseline', scope_type: scope, scope_id: inc.scope_id, current: 47.8, baseline: 39.0, robust_z: 2.7, samples: 512, ts: inc.updated_at },
376 + ],
377 + series: { step_seconds: step, points },
378 + probes: PROBES.filter((p) => inc.scope_type !== 'region' || p.region === inc.scope_id || inc.scope_type === 'asn').slice(0, inc.affected_probes).map((p) => ({ probe_id: p.probe_id, region: p.region, observation: p.region === 'na-east' ? 'TTFB +43 %, loss 3.1 % toward 41 targets' : 'Route change on 12 paths, latency shift +12 ms' })),
379 + targets: TARGETS.filter((t) => inc.affected_services.includes(t.service_id)).slice(0, 8).map((t) => ({ target_id: t.target_id, name: t.name, service_id: t.service_id, observation: `TTFB ${Math.round(t.ttfb_ms_median_1h * 1.4)} ms (baseline ${t.ttfb_ms_median_1h} ms)` })),
380 + bgp: inc.type === 'dns_disruption' || inc.type === 'service_degradation' ? null : { withdrawals_ratio: 4.76, announcements_ratio: 1.19, origin_changes: 3 },
381 + annotations: inc.status === 'resolved' ? [{ ts: iso(end), author: 'spb', text: 'Confirmed by vendor status page; matches submarine cable maintenance notice.' }] : [],
382 + };
383 +}
384 +
385 +// ---------------------------------------------------------------- fronts
386 +function fronts() {
387 + return [
388 + { id: 'front_na-east_eu-west', name: 'North Atlantic Pressure Front', status: 'developing', intensity: Number(clamp(74.0 + drift() * 2).toFixed(1)), confidence: 0.89, direction: 'east', since: iso(START - 19 * 60_000),
389 + from: { region: 'na-east', name: 'North America East', lat: 43, lon: -76 }, to: { region: 'eu-west', name: 'Western Europe', lat: 49, lon: 3 },
390 + observed: { latency_pct: 43.0, churn_x: 4.8, loss_pct: 3.1, pairs: 17, targets: 17, route_changes: 9 } },
391 + { id: 'front_na-east_na-central', name: 'Great Lakes Pressure Front', status: 'active', intensity: Number(clamp(41.5 + drift()).toFixed(1)), confidence: 0.64, direction: 'west', since: iso(START - 11 * 60_000),
392 + from: { region: 'na-east', name: 'North America East', lat: 43, lon: -76 }, to: { region: 'na-central', name: 'North America Central', lat: 41, lon: -95 },
393 + observed: { latency_pct: 18.0, churn_x: 1.9, loss_pct: 0.8, pairs: 6, targets: 9, route_changes: 3 } },
394 + ];
395 +}
396 +
397 +// ---------------------------------------------------------------- bgp / latency / ticker
398 +function bgpStats() {
399 + const d = DEGRADED ? 0 : drift();
400 + const w = Number((52.4 + d * 3).toFixed(1));
401 + const a = Number((760.0 + d * 20).toFixed(1));
402 + const collectors = [['rrc00', 'Amsterdam', 120.1, 8.0, 210], ['rrc01', 'London', 84.3, 6.1, 96], ['rrc03', 'Amsterdam (AMS-IX)', 96.7, 7.4, 180], ['rrc04', 'Geneva', 41.2, 2.9, 44], ['rrc05', 'Vienna', 38.8, 2.4, 61], ['rrc06', 'Otemachi', 52.0, 3.3, 30], ['rrc10', 'Milan', 47.5, 3.0, 58], ['rrc11', 'New York', 88.9, 9.8, 74], ['rrc12', 'Frankfurt', 71.4, 4.2, 130], ['rrc13', 'Moscow', 22.6, 1.1, 27], ['rrc14', 'Palo Alto', 59.0, 3.4, 52], ['rrc15', 'São Paulo', 37.5, 0.8, 41]]
403 + .map(([id, location, ann, wd, peers], i) => ({ id, location, announcements_per_s: ann, withdrawals_per_s: wd, peers, last_message: minutesAgo(DEGRADED ? 14 : 0), fresh: !DEGRADED && i !== 9 }));
404 + const series = [];
405 + const end = now() - (now() % 60_000);
406 + for (let i = 59; i >= 0; i--) { const boost = i < 22 ? 1 + (22 - i) / 22 * 3.5 : 1; series.push({ ts: iso(end - i * 60_000), announcements: Math.round(38400 + Math.sin(i / 4) * 2200 + (boost - 1) * 2600), withdrawals: Math.round(660 * boost + Math.sin(i / 3) * 60) }); }
407 + return { ts: engineTs(), fresh: !DEGRADED, updates_per_s: Number((a + w).toFixed(1)), announcements_per_s: a, withdrawals_per_s: w,
408 + baseline: { announcements_per_s: 640.0, withdrawals_per_s: 11.0 }, ratio: { announcements: Number((a / 640).toFixed(2)), withdrawals: Number((w / 11).toFixed(2)) },
409 + unique_prefixes_1m: 14211, unique_origins_1m: 2210, origin_changes_1m: 3, peers: 1450, collectors, series_1h: series,
410 + top_origins_1h: [[13335, 'Cloudflare', 340, 12], [6453, 'TATA Communications', 2210, 1840], [16509, 'Amazon', 690, 44], [3356, 'Lumen', 1120, 210], [9498, 'Bharti Airtel', 880, 96], [4134, 'China Telecom', 760, 71], [174, 'Cogent', 540, 48], [8075, 'Microsoft', 310, 9], [20940, 'Akamai', 220, 6], [1299, 'Arelion', 480, 130]].map(([asn, name, announcements, withdrawals]) => ({ asn, name, announcements, withdrawals })) };
411 +}
412 +const LAT_PAIRS = [['na-east', 'eu-west', 92.1, 88.0, 0.0, 0.6, 37], ['na-east', 'na-east', 18.4, 12.1, 3.1, 4.9, 44], ['na-east', 'eu-east-med', 131.0, 121.0, 1.2, 2.3, 21], ['na-east', 'asia-se', 226.0, 219.0, 0.4, 0.8, 19], ['na-east', 'oceania', 211.5, 208.0, 0.1, 0.4, 17], ['eu-west', 'eu-west', 9.8, 9.5, 0.0, 0.1, 61], ['eu-west', 'na-east', 94.3, 87.0, 0.9, 2.1, 40], ['eu-west', 'eu-east-med', 48.2, 47.0, 0.0, 0.3, 24], ['eu-west', 'asia-se', 168.7, 166.0, 0.2, 0.5, 22], ['eu-west', 'oceania', 258.0, 255.0, 0.0, 0.3, 14], ['eu-east-med', 'eu-west', 49.9, 47.0, 0.3, 0.9, 25], ['eu-east-med', 'na-east', 139.4, 121.0, 1.8, 3.2, 20], ['eu-east-med', 'mena', 71.0, 69.0, 0.0, 0.4, 12], ['asia-se', 'asia-east', 68.1, 67.0, 0.0, 0.2, 18], ['asia-se', 'na-east', 231.0, 219.0, 0.6, 1.4, 19], ['asia-se', 'asia-south', 61.3, 60.0, 0.1, 0.3, 15], ['asia-se', 'oceania', 96.0, 95.0, 0.0, 0.2, 13], ['oceania', 'na-west', 148.0, 146.0, 0.0, 0.3, 16], ['oceania', 'asia-se', 95.4, 95.0, 0.0, 0.1, 13], ['oceania', 'eu-west', 259.2, 255.0, 0.1, 0.4, 14]];
413 +function latency() {
414 + return { ts: engineTs(), global: { rtt_ms_median: 41.2, rtt_ms_baseline: 39.0, ttfb_ms_median: 118.0, ttfb_ms_baseline: 110.0, packet_loss_pct: 0.3 },
415 + matrix: LAT_PAIRS.map(([from, to, rtt, base, loss, z, pairs]) => ({ from, to, rtt_ms: rtt, rtt_ms_baseline: base, ttfb_ms: Number((rtt * 1.7 + 20).toFixed(1)), loss_pct: loss, z, pairs })),
416 + by_probe: PROBES.map((p, i) => ({ probe_id: p.probe_id, rtt_ms_median: [30.1, 31.4, 27.9, 22.0, 24.6, 44.8, 52.0, 61.3][i], ttfb_ms_median: [90.0, 96.2, 84.1, 71.0, 77.3, 118.0, 131.0, 140.5][i], loss_pct: [3.1, 2.8, 2.4, 0.0, 0.1, 0.9, 0.2, 0.0][i], z: [4.9, 4.4, 3.8, 0.2, 0.3, 1.1, 0.4, 0.1][i] })) };
417 +}
418 +function ticker() {
419 + const b = bgpStats();
420 + const regs = regionsList();
421 + const byLevel = (ids) => regs.filter((x) => ids.includes(x.level)).length;
422 + return { ts: iso(now() - (now() % 5000)), bgp_updates_per_s: b.updates_per_s, bgp_withdrawals_per_s: b.withdrawals_per_s, bgp_updates_per_min: Math.round(b.updates_per_s * 60),
423 + probes_active: DEGRADED ? 1 : 8, probes_total: 8, measurements_per_s: DEGRADED ? 1.4 : 12.3, measurements_per_min: DEGRADED ? 84 : 738,
424 + targets_degraded: TARGETS.filter((t) => t.pressure > 40).length, targets_total: TARGETS_TOTAL, regions_elevated: byLevel(['elevated', 'stressed', 'high']), regions_normal: byLevel(['calm', 'normal']), regions_severe: byLevel(['severe', 'extreme']),
425 + dns_failures_per_min: 2, median_global_rtt_ms: 41.2, route_changes_per_min: 1.2, active_incidents: activeIncidents().length, internal_status: internalStatus() };
426 +}
427 +
428 +// ---------------------------------------------------------------- routes
429 +const PAIRS = [['ca-qc-01', 'cloudflare-www-cloudflare', 3, false], ['ca-qc-01', 'aws-aws-amazon', 4, false], ['ca-bhs-01', 'github-github', 2, false], ['fr-gra-01', 'aws-s3-amazonaws', 1, false], ['ie-dub-01', 'google-www-google', 0, true], ['tr-ist-01', 'meta-www-facebook', 1, false], ['sg-sin-01', 'openai-api-openai', 0, true], ['au-syd-01', 'cloudflare-www-cloudflare', 0, true], ['ca-qc-02', 'slack-slack', 2, false], ['fr-gra-01', 'anthropic-api-anthropic', 0, true]];
430 +function hash(s) { let h = 0; for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0; return h.toString(16).padStart(8, '0') + (h * 7).toString(16).slice(0, 8); }
431 +function routes(probeId, targetId) {
432 + const probe = PROBES.find((p) => p.probe_id === probeId) ?? PROBES[0];
433 + const target = targetById[targetId] ?? TARGETS[0];
434 + const changed = (PAIRS.find(([p, t]) => p === probe.probe_id && t === target.target_id)?.[2] ?? 0) > 0;
435 + const transitAsn = probe.region === 'na-east' ? 6453 : probe.region === 'eu-west' ? 1299 : 2914;
436 + const hopsBase = [
437 + { n: 1, ip: '192.168.2.1', asn: null, asn_name: null, rtt_ms: 1.2, private: true },
438 + { n: 2, ip: '10.170.0.1', asn: null, asn_name: null, rtt_ms: 4.8, private: true },
439 + { n: 3, ip: '64.230.99.13', asn: probe.asn, asn_name: asnById[probe.asn]?.name ?? probe.provider, rtt_ms: 8.9, private: false },
440 + { n: 4, ip: '64.230.79.112', asn: probe.asn, asn_name: asnById[probe.asn]?.name ?? probe.provider, rtt_ms: 12.4, private: false },
441 + { n: 5, ip: '4.68.71.173', asn: 3356, asn_name: 'Lumen (Level 3)', rtt_ms: 18.7, private: false },
442 + { n: 6, ip: '4.69.140.46', asn: 3356, asn_name: 'Lumen (Level 3)', rtt_ms: 22.3, private: false },
443 + { n: 7, ip: '141.101.72.22', asn: target.asn, asn_name: asnById[target.asn]?.name ?? target.provider, rtt_ms: 28.1, private: false },
444 + { n: 8, ip: '104.16.132.229', asn: target.asn, asn_name: asnById[target.asn]?.name ?? target.provider, rtt_ms: 30.2, private: false },
445 + ];
446 + const hopsCur = changed ? [
447 + ...hopsBase.slice(0, 4),
448 + { n: 5, ip: '209.58.86.13', asn: transitAsn, asn_name: asnById[transitAsn]?.name, rtt_ms: 21.9, private: false },
449 + { n: 6, ip: '66.110.59.21', asn: transitAsn, asn_name: asnById[transitAsn]?.name, rtt_ms: 33.7, private: false },
450 + { n: 7, ip: '80.231.153.53', asn: transitAsn, asn_name: asnById[transitAsn]?.name, rtt_ms: 38.9, private: false },
451 + { n: 8, ip: '141.101.72.22', asn: target.asn, asn_name: asnById[target.asn]?.name ?? target.provider, rtt_ms: 41.0, private: false },
452 + { n: 9, ip: '104.16.132.229', asn: target.asn, asn_name: asnById[target.asn]?.name ?? target.provider, rtt_ms: 42.6, private: false },
453 + ] : hopsBase;
454 + const hBase = hash(`${probe.probe_id}|${target.target_id}|base`);
455 + const hCur = changed ? hash(`${probe.probe_id}|${target.target_id}|cur`) : hBase;
456 + const hAlt = hash(`${probe.probe_id}|${target.target_id}|alt`);
457 + const hist = [];
458 + const end = now() - (now() % 900_000);
459 + for (let i = 95; i >= 0; i--) { const cur = changed && i < 6; hist.push({ ts: iso(end - i * 900_000), route_hash: cur ? hCur : i % 17 === 5 ? hAlt : hBase, hop_count: cur ? hopsCur.length : i % 17 === 5 ? 9 : hopsBase.length, total_ms: cur ? 42.6 + r(-1, 1) : 30.2 + r(-0.8, 0.8) }); }
460 + return {
461 + probe: { probe_id: probe.probe_id, name: probe.name, asn: probe.asn }, target: { target_id: target.target_id, name: target.name, hostname: target.hostname, asn: target.asn },
462 + current: { ts: iso(end), route_hash: hCur, reached: true, total_ms: hopsCur[hopsCur.length - 1].rtt_ms, hops: hopsCur },
463 + baseline: { route_hash: hBase, share_7d: changed ? 0.82 : 0.94, first_seen: iso(START - 6.4 * 86400_000), last_seen: changed ? iso(end - 6 * 900_000) : iso(end), hops: hopsBase },
464 + diff: { changed, added: changed ? hopsCur.slice(4, 7).map((h) => ({ n: h.n, ip: h.ip, asn: h.asn })) : [], removed: changed ? hopsBase.slice(4, 6).map((h) => ({ n: h.n, ip: h.ip, asn: h.asn })) : [],
465 + asn_path_current: changed ? [probe.asn, transitAsn, target.asn] : [probe.asn, 3356, target.asn], asn_path_baseline: [probe.asn, 3356, target.asn], latency_shift_ms: changed ? 12.4 : 0.0, hop_delta: changed ? 1 : 0 },
466 + history_24h: hist,
467 + route_share_7d: changed ? [{ route_hash: hBase, share: 0.82, asn_path: [probe.asn, 3356, target.asn] }, { route_hash: hCur, share: 0.11, asn_path: [probe.asn, transitAsn, target.asn] }, { route_hash: hAlt, share: 0.07, asn_path: [probe.asn, 174, target.asn] }]
468 + : [{ route_hash: hBase, share: 0.94, asn_path: [probe.asn, 3356, target.asn] }, { route_hash: hAlt, share: 0.06, asn_path: [probe.asn, 174, target.asn] }],
469 + };
470 +}
471 +
472 +// ---------------------------------------------------------------- history summary
473 +function historySummary(year, month) {
474 + const top_events = allIncidents().sort((a, b) => b.peak_pressure - a.peak_pressure);
475 + const top_asns = [[6453, 'TATA Communications', 3, 58.4], [3356, 'Lumen (Level 3)', 2, 61.4], [577, 'Bell Canada', 2, 76.0], [9121, 'Turk Telekom', 1, 66.8], [36459, 'GitHub', 1, 44.1]].map(([asn, name, events, max_pressure]) => ({ asn, name, events, max_pressure }));
476 + const top_regions = [['na-east', 'North America East', 3, 76.0, 9.4], ['eu-west', 'Western Europe', 2, 48.9, 4.1], ['eu-east-med', 'Eastern Mediterranean', 1, 66.8, 2.7], ['asia-se', 'Southeast Asia', 1, 39.7, 1.2]].map(([id, name, events, max_pressure, hours_elevated]) => ({ id, name, events, max_pressure, hours_elevated }));
477 + const byType = (t) => top_events.find((e) => e.type === t) ?? null;
478 + const largest = { pressure: top_events[0], routing: byType('routing_instability'), dns: byType('dns_disruption'), latency: byType('regional_latency') };
479 + const base = { top_events, top_asns, top_regions, largest, available_months: ['2026-09'] };
480 + if (year && month) {
481 + const days = [];
482 + const dim = new Date(Date.UTC(year, month, 0)).getUTCDate();
483 + const today = new Date();
484 + for (let d = 1; d <= dim; d++) {
485 + const dt = new Date(Date.UTC(year, month - 1, d));
486 + if (dt > today) break;
487 + if (dt < new Date(Date.UTC(2026, 8, 6))) { continue; } // observatory started 2026-09-06
488 + const dayEvents = allIncidents().filter((e) => e.started_at.startsWith(dt.toISOString().slice(0, 10))).length;
489 + const max = Number(clamp(31 + r(-4, 6) + (dayEvents ? r(8, 30) : 0)).toFixed(1));
490 + days.push({ date: dt.toISOString().slice(0, 10), min: Number(clamp(max - r(14, 22)).toFixed(1)), max, avg: Number(clamp(max - r(6, 12)).toFixed(1)), events: dayEvents });
491 + }
492 + return { year, month, days, ...base };
493 + }
494 + const months = [{ month: '2026-09', min: 18.0, max: 76.0, avg: 30.2, events: allIncidents().length, days_observed: 7 }];
495 + return year ? { year, months, ...base } : { years: [2026], months, ...base };
496 +}
497 +
498 +// ---------------------------------------------------------------- explain / methodology
499 +function explain() {
500 + const g = globalPressure();
501 + const sig = (component) => ({
502 + routing: [{ signal_id: 'bgp_withdrawals_z', label: 'BGP withdrawals/s vs baseline', scope_type: 'global', scope_id: null, current: 52.4, baseline_median: 11.0, mad: 2.1, robust_z: 8.0, samples: 60, stress: 0.92, contribution: 9.1 }, { signal_id: 'bgp_announcements_z', label: 'BGP announcements/s vs baseline', scope_type: 'global', scope_id: null, current: 760.0, baseline_median: 640.0, mad: 48.0, robust_z: 2.5, samples: 60, stress: 0.31, contribution: 2.0 }, { signal_id: 'bgp_origin_changes_z', label: 'Origin ASN changes vs baseline', scope_type: 'global', scope_id: null, current: 3, baseline_median: 1, mad: 0.7, robust_z: 2.9, samples: 60, stress: 0.36, contribution: 2.9 }, { signal_id: 'bgp_collector_disagreement', label: 'Collector disagreement', scope_type: 'global', scope_id: null, current: 0.21, baseline_median: 0.06, mad: 0.02, robust_z: 7.5, samples: 12, stress: 0.6, contribution: 0.3 }],
503 + latency: [{ signal_id: 'ttfb_z', label: 'HTTP time-to-first-byte vs baseline', scope_type: 'region', scope_id: 'na-east', current: 161.0, baseline_median: 110.0, mad: 9.0, robust_z: 5.6, samples: 412, stress: 0.71, contribution: 6.2 }, { signal_id: 'loss', label: 'Packet loss', scope_type: 'region', scope_id: 'na-east', current: 3.1, baseline_median: 0.2, mad: 0.2, robust_z: 8.0, samples: 380, stress: 0.66, contribution: 2.6 }, { signal_id: 'rtt_z', label: 'ICMP round-trip time vs baseline', scope_type: 'global', scope_id: null, current: 41.2, baseline_median: 39.0, mad: 1.4, robust_z: 1.6, samples: 2048, stress: 0.12, contribution: 0.4 }, { signal_id: 'tcp_z', label: 'TCP connect latency vs baseline', scope_type: 'region', scope_id: 'eu-west', current: 24.1, baseline_median: 23.8, mad: 1.1, robust_z: 0.3, samples: 620, stress: 0.02, contribution: -0.3 }],
504 + dns: [{ signal_id: 'dns_fail_rate', label: 'DNS SERVFAIL / timeout rate', scope_type: 'global', scope_id: null, current: 0.0034, baseline_median: 0.0028, mad: 0.0006, robust_z: 1.0, samples: 1810, stress: 0.14, contribution: 1.4 }, { signal_id: 'dns_latency_z', label: 'DNS lookup latency vs baseline', scope_type: 'global', scope_id: null, current: 21.2, baseline_median: 20.1, mad: 1.9, robust_z: 0.6, samples: 1810, stress: 0.06, contribution: 0.9 }, { signal_id: 'resolver_disagreement', label: 'Resolver disagreement', scope_type: 'global', scope_id: null, current: 0.01, baseline_median: 0.01, mad: 0.004, robust_z: 0.0, samples: 1810, stress: 0.0, contribution: 0.6 }],
505 + availability: [{ signal_id: 'target_down_corroborated', label: 'Targets failing from ≥2 probe regions', scope_type: 'global', scope_id: null, current: 4, baseline_median: 0, mad: 0.5, robust_z: 8.0, samples: TARGETS_TOTAL, stress: 0.46, contribution: 3.9 }, { signal_id: 'fail_rate_z', label: 'Failure rate vs baseline', scope_type: 'service', scope_id: 'aws', current: 0.021, baseline_median: 0.01, mad: 0.003, robust_z: 3.7, samples: 240, stress: 0.4, contribution: 1.7 }],
506 + http_tls: [{ signal_id: 'tls_fail_rate', label: 'TLS handshake failures', scope_type: 'global', scope_id: null, current: 0.008, baseline_median: 0.005, mad: 0.001, robust_z: 3.0, samples: 3400, stress: 0.35, contribution: 1.6 }, { signal_id: 'http_5xx_rate', label: 'HTTP 5xx rate', scope_type: 'global', scope_id: null, current: 0.0065, baseline_median: 0.005, mad: 0.001, robust_z: 1.5, samples: 3400, stress: 0.2, contribution: 1.2 }, { signal_id: 'reset_timeout_rate', label: 'Connection resets / timeouts', scope_type: 'global', scope_id: null, current: 0.004, baseline_median: 0.004, mad: 0.001, robust_z: 0.0, samples: 3400, stress: 0.0, contribution: 0.0 }],
507 + path: [{ signal_id: 'route_change_rate', label: 'Route fingerprint changes vs baseline churn', scope_type: 'region', scope_id: 'na-east', current: 0.62, baseline_median: 0.08, mad: 0.03, robust_z: 8.0, samples: 96, stress: 0.8, contribution: 3.4 }, { signal_id: 'hop_count_z', label: 'Hop count deviation', scope_type: 'global', scope_id: null, current: 11.4, baseline_median: 10.9, mad: 0.6, robust_z: 0.8, samples: 96, stress: 0.1, contribution: 0.4 }, { signal_id: 'path_latency_shift', label: 'Latency shift on changed paths', scope_type: 'asn', scope_id: '6453', current: 12.4, baseline_median: 0.8, mad: 1.1, robust_z: 8.0, samples: 41, stress: 0.7, contribution: 1.7 }],
508 + corroboration: [{ signal_id: 'vendor_incidents', label: 'Public incidents declared by major providers', scope_type: 'service', scope_id: 'aws', current: 1, baseline_median: 0, mad: 0.3, robust_z: 3.3, samples: 22, stress: 0.22, contribution: 1.1 }],
509 + })[component];
510 + return { ts: g.ts, pressure: g.pressure, components: g.components.map((c) => ({ id: c.id, score: c.score, weight: c.weight, contribution: c.contribution, signals: sig(c.id) })), excluded_probes: [],
511 + notes: ['Baseline: trailing 7 days, same hour of day ±1 h, most recent 10 minutes excluded.', 'Robust z clipped to [−3, 8]; component score = 100 × (1 − e^(−0.35 × stress)).', 'Weights sum to 1.0 (pressure.yaml v1).'] };
512 +}
513 +const METHODOLOGY = { weights: WEIGHTS, levels: LEVELS, engine: { cycle_seconds: 10, window_seconds: 120, bgp_window_seconds: 60, baseline_days: 7, baseline_exclude_seconds: 600, baseline_min_samples: 24, seasonality: 'hour_of_day', seasonality_min_days: 3, z_clip_low: -3.0, z_clip_high: 8.0, z_anomaly: 3.0, saturation_k: 0.35, min_probes_for_scoring: 2, probe_fresh_seconds: 180, probe_local_failure_ratio: 0.8, bgp_fresh_seconds: 120 },
514 + components: { latency: { signals: [{ id: 'ttfb_z', label: 'HTTP time-to-first-byte vs baseline', weight: 0.35 }, { id: 'tcp_z', label: 'TCP connect latency vs baseline', weight: 0.25 }, { id: 'rtt_z', label: 'ICMP round-trip time vs baseline', weight: 0.25 }, { id: 'loss', label: 'Packet loss', weight: 0.15 }] }, dns: { signals: [{ id: 'dns_fail_rate', label: 'DNS SERVFAIL / timeout rate', weight: 0.45 }, { id: 'dns_latency_z', label: 'DNS lookup latency vs baseline', weight: 0.3 }, { id: 'resolver_disagreement', label: 'Resolver disagreement', weight: 0.25 }] }, availability: { signals: [{ id: 'target_down_corroborated', label: 'Targets failing from ≥2 probe regions', weight: 0.7 }, { id: 'fail_rate_z', label: 'Failure rate vs baseline', weight: 0.3 }] }, http_tls: { signals: [{ id: 'http_5xx_rate', label: 'HTTP 5xx rate', weight: 0.35 }, { id: 'tls_fail_rate', label: 'TLS handshake failures', weight: 0.35 }, { id: 'reset_timeout_rate', label: 'Connection resets / timeouts', weight: 0.3 }] }, path: { signals: [{ id: 'route_change_rate', label: 'Route fingerprint changes vs baseline churn', weight: 0.6 }, { id: 'hop_count_z', label: 'Hop count deviation', weight: 0.2 }, { id: 'path_latency_shift', label: 'Latency shift on changed paths', weight: 0.2 }] }, routing: { signals: [{ id: 'bgp_withdrawals_z', label: 'BGP withdrawals/s vs baseline', weight: 0.4 }, { id: 'bgp_announcements_z', label: 'BGP announcements/s vs baseline', weight: 0.25 }, { id: 'bgp_origin_changes_z', label: 'Origin ASN changes vs baseline', weight: 0.2 }, { id: 'bgp_collector_disagreement', label: 'Collector disagreement', weight: 0.15 }] }, corroboration: { signals: [{ id: 'vendor_incidents', label: 'Public incidents declared by major providers', weight: 1.0 }] } },
515 + events: { detect_threshold: 45, confirm_cycles: 2, active_cycles: 6, recover_threshold: 30, resolve_after_seconds: 600, min_confidence: 0.45 }, fronts: { min_pairs: 3, z_threshold: 2.5, min_intensity: 35 },
516 + version: 1, updated_at: '2026-09-12T01:54:00Z' };
517 +
518 +// ---------------------------------------------------------------- detail objects
519 +const probeList = () => PROBES.map((p) => ({ ...p, last_seen: DEGRADED && p.probe_id !== 'fr-gra-01' ? iso(START - 14 * 60_000) : iso(now() - (now() % 10_000) - 3000), status: DEGRADED && p.probe_id !== 'fr-gra-01' ? 'stale' : 'online' }));
520 +const targetRow = (t) => ({ target_id: t.target_id, name: t.name, pressure: t.pressure, ok_ratio_1h: t.ok_ratio_1h, ttfb_ms_median: t.ttfb_ms_median_1h });
521 +const serviceObj = ([slug, name, category, asn, source, indicator]) => {
522 + const p = SERVICE_PRESSURE[slug];
523 + const targets = TARGETS.filter((t) => t.service_id === slug);
524 + const affected = slug === 'aws' ? ['na-east', 'eu-west'] : slug === 'github' || slug === 'slack' ? ['na-east'] : [];
525 + return { slug, name, category, pressure: p, level: levelOf(p).id, observed_availability_24h: Number(clamp(1 - p / 4000 - (p > 30 ? 0.002 : 0), 0, 1).toFixed(4)), targets: targets.length, affected_regions: affected,
526 + vendor_status: source ? { indicator, incidents: indicator === 'none' ? 0 : 1, source, checked_at: minutesAgo(1) } : null, asn };
527 +};
528 +function serviceDetail(svc) {
529 + const s = serviceObj(svc);
530 + const targets = TARGETS.filter((t) => t.service_id === s.slug);
531 + const observedAff = s.affected_regions.map((id) => ({ id, name: REGION_DEFS.find((d) => d[0] === id)[1], observation: id === 'na-east' ? 'Elevated TTFB (+38 %) and 2.1× failure rate from 3 probes' : 'Elevated TLS latency from 2 probes' }));
532 + const vendorNone = s.vendor_status && s.vendor_status.indicator === 'none';
533 + return { ...s, affected_regions: observedAff,
534 + observed: { availability_24h: s.observed_availability_24h, availability_1h: Number(clamp(s.observed_availability_24h - (s.pressure > 30 ? 0.004 : 0), 0, 1).toFixed(4)), ttfb_ms_median_1h: targets[0]?.ttfb_ms_median_1h ?? 80, ttfb_ms_baseline: Math.round((targets[0]?.ttfb_ms_median_1h ?? 80) * (s.pressure > 30 ? 0.72 : 0.96)), tls_ms_median_1h: 30.1, failures_1h: s.pressure > 30 ? 14 : 2 },
535 + vendor_status: s.vendor_status ? { ...s.vendor_status, titles: s.vendor_status.incidents ? ['Increased error rates in US-EAST-1 (EC2 API)'] : [], url: `https://${s.vendor_status.source}` } : null,
536 + discrepancy: observedAff.length && vendorNone ? `Vendor reports no incident; we observe ${observedAff[0].observation.toLowerCase()}.` : null,
537 + matrix: probeList().map((p) => ({ probe_id: p.probe_id, probe_region: p.region, targets: targets.map((t) => { const stress = p.region === 'na-east' && s.pressure > 30 ? 2.4 : 0.6; const z = Number((r(-0.5, 1.2) * stress + (stress > 1 ? 1.4 : 0)).toFixed(1)); return { target_id: t.target_id, ok: z < 4.5, ttfb_ms: Number((t.ttfb_ms_median_1h * (1 + Math.max(0, z) * 0.12)).toFixed(1)), z, ts: p.last_seen }; }) })),
538 + targets: targets.map(targetRow), history_24h: history24('service', s.slug), incidents: allIncidents().filter((i) => i.affected_services.includes(s.slug)) };
539 +}
540 +function asnDetail(a) {
541 + const targets = TARGETS.filter((t) => t.asn === a.asn);
542 + const lvl = levelOf(a.pressure);
543 + const churn = a.asn === 6453 ? 4.9 : 1.1;
544 + const series = [];
545 + const end = now() - (now() % 3600_000);
546 + for (let i = 23; i >= 0; i--) series.push({ ts: iso(end - i * 3600_000), announcements: Math.round((a.prefixes_observed / 4) * (i === 0 && a.asn === 6453 ? churn : 1) * (0.8 + Math.sin(i) * 0.1)), withdrawals: Math.round((a.prefixes_observed / 90) * (i === 0 && a.asn === 6453 ? churn * 3 : 1)) });
547 + return { asn: a.asn, name: a.name, country: a.country, importance: a.importance, ts: engineTs(), pressure: a.pressure, level: lvl.id, level_label: lvl.label, delta_1h: a.asn === 6453 ? 22.4 : a.asn === 577 ? 9.1 : -1.0, trend: a.asn === 6453 || a.asn === 577 ? 'rising' : 'falling', confidence: 0.7,
548 + components: { routing: Number(clamp(a.pressure * 0.7 * churn).toFixed(1)), latency: Number(clamp(a.pressure * 1.2).toFixed(1)), availability: Number(clamp(a.pressure * 0.5).toFixed(1)), dns: Number(clamp(a.pressure * 0.2).toFixed(1)), http_tls: Number(clamp(a.pressure * 0.3).toFixed(1)), path: Number(clamp(a.pressure * 0.8).toFixed(1)) },
549 + bgp: { prefixes_observed_24h: a.prefixes_observed, announcements_1h: series[23].announcements, withdrawals_1h: series[23].withdrawals, churn_ratio: churn, origin_changes_1h: a.asn === 6453 ? 2 : 0, path_stability: a.asn === 6453 ? 0.61 : 0.97, series_24h: series },
550 + regions_observed: a.asn === 6453 ? ['na-east', 'eu-west', 'eu-east-med', 'asia-se'] : ['na-east', 'eu-west', 'eu-east-med'], targets: targets.map(targetRow), history_24h: history24('asn', String(a.asn)), incidents: allIncidents().filter((i) => i.affected_asns.includes(a.asn)) };
551 +}
552 +function regionDetail(id) {
553 + const rg = regionObj(id);
554 + const asns = ASNS.filter((a) => (id === 'na-east' ? ['CA', 'US'].includes(a.country) : id === 'eu-west' ? ['FR', 'NL', 'DE', 'SE'].includes(a.country) : true)).slice(0, 8);
555 + const svcs = SERVICES.map(serviceObj).filter((s) => TARGETS.some((t) => t.service_id === s.slug && t.region === id)).slice(0, 10);
556 + return { ...rg, history_24h: history24('region', id), baseline_7d: { median: Number((rg.pressure * 0.62).toFixed(1)), p90: Number((rg.pressure * 0.95).toFixed(1)) }, incidents: allIncidents().filter((i) => i.scope_id === id),
557 + top_asns: asns.map((a) => ({ asn: a.asn, name: a.name, pressure: a.pressure })), top_services: svcs.map((s) => ({ slug: s.slug, name: s.name, pressure: s.pressure, observed_availability_24h: s.observed_availability_24h })),
558 + probes: probeList().filter((p) => p.region === id), matrix: latency().matrix.filter((m) => m.from === id || m.to === id) };
559 +}
560 +function countryDetail(cc) {
561 + const c = countryObj(cc);
562 + const targets = TARGETS.filter((t) => t.country === cc);
563 + return { ...c, history_24h: history24('country', cc), baseline_7d: { median: Number((c.pressure * 0.7).toFixed(1)), p90: Number((c.pressure * 1.02).toFixed(1)) }, incidents: allIncidents().filter((i) => i.scope_id === cc || (i.scope_id === c.region && i.scope_type === 'region')),
564 + asns: ASNS.filter((a) => a.country === cc).map((a) => ({ asn: a.asn, name: a.name, pressure: a.pressure })), services: [...new Set(targets.map((t) => t.service_id))].map((slug) => serviceObj(SERVICES.find((s) => s[0] === slug))).map((s) => ({ slug: s.slug, name: s.name, pressure: s.pressure, observed_availability_24h: s.observed_availability_24h })),
565 + probes: probeList().filter((p) => p.country === cc), targets: targets.map((t) => ({ target_id: t.target_id, name: t.name, category: t.category, pressure: t.pressure, ok_ratio_1h: t.ok_ratio_1h, ttfb_ms_median: t.ttfb_ms_median_1h })) };
566 +}
567 +function targetDetail(t) {
568 + const pts = [];
569 + const end = now() - (now() % 60_000);
570 + for (let i = 1439; i >= 0; i -= 5) pts.push({ ts: iso(end - i * 60_000), ttfb_ms_p50: Number((t.ttfb_ms_median_1h * (1 + (i < 45 && t.pressure > 35 ? (45 - i) / 45 * 0.4 : 0) + Math.sin(i / 20) * 0.04)).toFixed(1)), ok_ratio: Number(clamp(i < 45 && t.pressure > 40 ? 0.93 + r(0, 0.05, 3) : 1, 0, 1).toFixed(3)) });
571 + return { ...t, latest_by_probe: probeList().map((p) => ({ probe_id: p.probe_id, kind: 'http', ts: p.last_seen, ok: true, error: null, dns_ms: r(4, 30), tcp_ms: r(8, 90), tls_ms: r(10, 120), ttfb_ms: Number((t.ttfb_ms_median_1h * (p.region === 'na-east' && t.pressure > 35 ? 1.4 : 1) + r(-8, 8)).toFixed(1)), http_status: 200, resolved_ip: `104.16.${Math.floor(rnd() * 255)}.${Math.floor(rnd() * 255)}`, packet_loss: p.region === 'na-east' ? 3.1 : 0.0, rtt_avg_ms: r(10, 200), z: p.region === 'na-east' && t.pressure > 35 ? r(3, 6) : r(-1, 1.5) })),
572 + series_24h: { step_seconds: 300, points: pts }, dns: { resolvers: [['local', 'NOERROR', 2, 4.1], ['8.8.8.8', 'NOERROR', 2, 12.3], ['1.1.1.1', 'NOERROR', 2, 9.8], ['9.9.9.9', 'NOERROR', 2, 14.6]].map(([resolver, rcode, answers, ms]) => ({ resolver, rcode, answers, ms })), disagreement: false } };
573 +}
574 +function search(q) {
575 + const s = q.trim().toLowerCase();
576 + if (!s) return [];
577 + const out = [];
578 + for (const c of countriesList()) if (c.name.toLowerCase().includes(s) || c.cc.toLowerCase() === s) out.push({ type: 'country', id: c.cc, label: c.name, href: `/country/${c.cc.toLowerCase()}`, pressure: c.pressure });
579 + for (const rg of regionsList()) if (rg.name.toLowerCase().includes(s) || rg.id.includes(s)) out.push({ type: 'region', id: rg.id, label: rg.name, href: `/internet/${rg.id}`, pressure: rg.pressure });
580 + for (const a of ASNS) if (a.name.toLowerCase().includes(s) || String(a.asn).includes(s.replace(/^as/, ''))) out.push({ type: 'asn', id: String(a.asn), label: `AS${a.asn} ${a.name}`, href: `/asn/${a.asn}`, pressure: a.pressure });
581 + for (const sv of SERVICES) if (sv[1].toLowerCase().includes(s) || sv[0].includes(s)) out.push({ type: 'service', id: sv[0], label: sv[1], href: `/service/${sv[0]}`, pressure: SERVICE_PRESSURE[sv[0]] });
582 + for (const t of TARGETS) if (t.hostname.includes(s)) out.push({ type: 'target', id: t.target_id, label: t.hostname, href: `/targets?q=${encodeURIComponent(t.hostname)}`, pressure: t.pressure });
583 + for (const i of allIncidents()) if (i.title.toLowerCase().includes(s)) out.push({ type: 'incident', id: i.slug, label: i.title, href: `/event/${i.slug}`, pressure: i.peak_pressure });
584 + return out.slice(0, 20);
585 +}
586 +
587 +// ---------------------------------------------------------------- admin state
588 +let adminConfig = JSON.parse(JSON.stringify({ version: 1, pressure_weights: WEIGHTS, levels: LEVELS, engine: METHODOLOGY.engine, components: METHODOLOGY.components, importance_weights: { 1: 0.4, 2: 0.7, 3: 1.0, 4: 1.5, 5: 2.2 }, events: METHODOLOGY.events, fronts: METHODOLOGY.fronts, scheduler: { tiers: { 1: 20, 2: 45, 3: 180 }, dns_every: 60, ping_every: 30, traceroute_every: 900, boost_factor: 0.5, boost_seconds: 900, batch_flush_seconds: 10, max_batch: 500, config_refresh_seconds: 300 } }));
589 +const adminTargets = TARGETS.map((t) => ({ ...t, enabled: true }));
590 +const adminProbes = PROBES.map((p) => ({ ...p, enabled: true }));
591 +const adminAnnotations = [{ id: 'ann_1', ts: iso(START - 590 * 60_000), author: 'spb', scope_type: 'region', scope_id: 'eu-west', text: 'Quad9 confirmed European resolver incident.' }];
592 +const incidentReview = {};
593 +function adminOverview() {
594 + const b = bgpStats();
595 + return { probes: probeList().map((p, i) => ({ ...p, health: { uptime_24h: p.uptime_24h, clock_offset_ms: p.clock_offset_ms, missing_ratio_1h: [0.002, 0.004, 0.001, 0.0, 0.003, 0.011, 0.006, 0.002][i], error_rate_1h: [0.031, 0.028, 0.024, 0.004, 0.006, 0.019, 0.007, 0.003][i], buffered: [0, 0, 0, 0, 0, 12, 0, 0][i], spool_bytes: [0, 0, 0, 0, 0, 48120, 0, 0][i], version: p.version, last_health: p.last_seen } })),
596 + ingest: { batches_per_min: 62, measurements_per_min: 738, rejected_per_min: 0, last_batch: minutesAgo(0) },
597 + stores: { clickhouse: { ok: true, inserts_per_s: 14.2, tables: [['measurements', 41_221_930, 6_412_000_000, iso(START - 6.4 * 86400_000), minutesAgo(0)], ['traceroutes', 214_880, 912_000_000, iso(START - 6.4 * 86400_000), minutesAgo(2)], ['bgp_events', 189_400_120, 21_800_000_000, iso(START - 2 * 86400_000), minutesAgo(0)], ['bgp_stats', 9_216, 1_900_000, iso(START - 6.4 * 86400_000), minutesAgo(0)], ['pressure_history', 55_296, 12_400_000, iso(START - 6.4 * 86400_000), minutesAgo(0)], ['signal_features', 2_211_840, 480_000_000, iso(START - 6.4 * 86400_000), minutesAgo(0)], ['probe_health', 92_160, 8_100_000, iso(START - 6.4 * 86400_000), minutesAgo(0)]].map(([name, rows, bytes, oldest, newest]) => ({ name, rows, bytes, oldest, newest })) }, postgres: { ok: true, size_bytes: 188_000_000 }, redis: { ok: true, used_memory_bytes: 41_000_000, keys: 1284 } },
598 + bgp: { collectors: b.collectors, messages_per_s: b.updates_per_s, fresh: b.fresh, reconnects_24h: 2 },
599 + engine: { last_run: engineTs(), cycle_ms_p50: 412, cycle_ms_max: 1180, runs_1h: 360, errors_1h: 0, internal_status: internalStatus(), excluded_probes: [] },
600 + corroboration: [['cloudflare-status', 'Cloudflare status', true, 0], ['aws-health', 'AWS Health', true, 1], ['github-status', 'GitHub status', true, 0], ['google-cloud-status', 'Google Cloud status', true, 0], ['azure-status', 'Azure status', false, 0], ['ripe-ris', 'RIPE RIS Live', true, 0]].map(([id, name, ok, incidents]) => ({ id, name, ok, last_fetch: minutesAgo(ok ? 2 : 41), incidents })) };
601 +}
602 +function baselines(signal_id) {
603 + const pts = [];
604 + const end = now() - (now() % 60_000);
605 + const median = signal_id === 'ttfb_z' ? 110 : signal_id === 'bgp_withdrawals_z' ? 11 : 40;
606 + const mad = median * 0.08;
607 + for (let i = 1439; i >= 0; i -= 5) { const spike = i < 45 ? (45 - i) / 45 * median * 0.45 : 0; const value = Number((median + Math.sin(i / 30) * mad * 1.2 + spike + r(-mad * 0.6, mad * 0.6)).toFixed(1)); pts.push({ ts: iso(end - i * 60_000), value, median, mad: Number(mad.toFixed(2)), z: Number(((value - median) / mad).toFixed(2)) }); }
608 + return { signal_id, points: pts, samples: 8064, baseline_days: 7 };
609 +}
610 +function rawTable(table, limit) {
611 + const cols = { measurements: ['ts', 'probe_id', 'target_id', 'kind', 'ok', 'dns_ms', 'tcp_ms', 'tls_ms', 'ttfb_ms', 'http_status'], traceroutes: ['ts', 'probe_id', 'target_id', 'route_hash', 'hop_count', 'total_ms', 'reached'], bgp_events: ['ts', 'collector', 'peer_asn', 'prefix', 'origin_asn', 'event_type', 'as_path'], bgp_stats: ['ts', 'collector', 'announcements', 'withdrawals', 'peers'], pressure_history: ['ts', 'scope_type', 'scope_id', 'pressure', 'confidence'], signal_features: ['ts', 'signal_id', 'scope_type', 'scope_id', 'current', 'median', 'mad', 'robust_z'], probe_health: ['ts', 'probe_id', 'uptime', 'clock_offset_ms', 'buffered', 'version'] }[table] ?? ['ts', 'value'];
612 + const rows = [];
613 + for (let i = 0; i < Math.min(limit, 200); i++) {
614 + const ts = iso(now() - i * 7000);
615 + const p = PROBES[i % PROBES.length];
616 + const t = TARGETS[(i * 7) % TARGETS.length];
617 + rows.push({ measurements: [ts, p.probe_id, t.target_id, 'http', true, r(3, 30), r(8, 90), r(10, 120), r(40, 300), 200], traceroutes: [ts, p.probe_id, t.target_id, hash(ts + p.probe_id), 8 + (i % 4), r(20, 80), true], bgp_events: [ts, 'rrc00', 3356, `104.16.${i % 255}.0/24`, 13335, i % 9 === 0 ? 'W' : 'A', '3356 13335'], bgp_stats: [ts, 'rrc00', 118 + i % 13, 8 + i % 5, 210], pressure_history: [ts, 'global', null, pressureAt(now() - i * 10_000), 0.85], signal_features: [ts, 'ttfb_z', 'region', 'na-east', 161 - i * 0.3, 110, 9, Number(((161 - i * 0.3 - 110) / 9).toFixed(2))], probe_health: [ts, p.probe_id, 0.998, p.clock_offset_ms, 0, p.version] }[table] ?? [ts, i]);
618 + }
619 + return { columns: cols, rows };
620 +}
621 +
622 +// ---------------------------------------------------------------- HTTP plumbing
623 +const json = (res, status, body, extra = {}) => {
624 + const data = JSON.stringify(body);
625 + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'X-IP-Admin-Token, Content-Type', 'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS', ...extra });
626 + res.end(data);
627 +};
628 +const notFound = (res) => json(res, 404, { error: 'not_found' });
629 +const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { resolve(b ? JSON.parse(b) : {}); } catch { resolve({}); } }); });
630 +
631 +// SSE clients
632 +const clients = new Set();
633 +let eventId = 1;
634 +function broadcast(event, data) {
635 + const payload = `id: ${eventId++}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
636 + for (const res of clients) res.write(payload);
637 +}
638 +setInterval(() => { for (const res of clients) res.write(': ping\n\n'); }, 15_000);
639 +setInterval(() => {
640 + if (DEGRADED) { broadcast('internal_status', { ts: iso(now()), internal_status: 'degraded', reason: 'Only 1 of 8 probes fresh (min 2); score frozen' }); return; }
641 + cycle++;
642 + broadcast('global_pressure_update', globalPressure());
643 + broadcast('regional_pressure_update', { ts: engineTs(), regions: regionsList(), countries: countriesList().map((c) => ({ cc: c.cc, pressure: c.pressure, level: c.level, delta_1h: c.delta_1h })) });
644 + broadcast('front_update', { ts: engineTs(), fronts: fronts() });
645 + broadcast('probe_stats', { ts: engineTs(), probes_active: 8, probes_total: 8, measurements_per_s: 12.3, excluded: [] });
646 + if (cycle % 6 === 0) broadcast('incident_updated', incidentNaEast());
647 +}, 10_000);
648 +setInterval(() => { broadcast('ticker', ticker()); const b = bgpStats(); broadcast('bgp_stats', { ts: b.ts, updates_per_s: b.updates_per_s, announcements_per_s: b.announcements_per_s, withdrawals_per_s: b.withdrawals_per_s, ratio: b.ratio, fresh: b.fresh }); }, 5_000);
649 +
650 +const server = http.createServer(async (req, res) => {
651 + const url = new URL(req.url, `http://${req.headers.host}`);
652 + const path = url.pathname.replace(/\/+$/, '') || '/';
653 + const q = url.searchParams;
654 + if (req.method === 'OPTIONS') return json(res, 204, {});
655 +
656 + // ---- SSE
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': '*' });
659 + res.write('retry: 5000\n\n');
660 + res.write(`id: ${eventId++}\nevent: snapshot\ndata: ${JSON.stringify({ global: globalPressure(), ticker: ticker(), regions: regionsList(), fronts: fronts(), incidents: activeIncidents() })}\n\n`);
661 + 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`);
662 + clients.add(res);
663 + req.on('close', () => clients.delete(res));
664 + return;
665 + }
666 +
667 + // ---- admin
668 + if (path.startsWith('/api/admin')) {
669 + if (req.headers['x-ip-admin-token'] !== ADMIN_TOKEN) return json(res, 401, { error: 'unauthorized' });
670 + const body = ['POST', 'PUT', 'PATCH'].includes(req.method) ? await readBody(req) : {};
671 + if (path === '/api/admin/overview') return json(res, 200, adminOverview());
672 + if (path === '/api/admin/targets' && req.method === 'GET') return json(res, 200, { targets: adminTargets });
673 + if (path === '/api/admin/targets' && req.method === 'POST') { if (!body.target_id || !body.hostname) return json(res, 422, { error: 'validation', detail: 'target_id and hostname required' }); const t = { importance: 3, tier: 2, enabled: true, pressure: 0, ok_ratio_1h: 1, ttfb_ms_median_1h: 0, ...body }; adminTargets.unshift(t); return json(res, 201, t); }
674 + let m = path.match(/^\/api\/admin\/targets\/([^/]+)$/);
675 + if (m) { const i = adminTargets.findIndex((t) => t.target_id === decodeURIComponent(m[1])); if (i < 0) return notFound(res); if (req.method === 'PATCH') { Object.assign(adminTargets[i], body); return json(res, 200, adminTargets[i]); } if (req.method === 'DELETE') { adminTargets.splice(i, 1); return json(res, 200, { ok: true }); } return json(res, 200, adminTargets[i]); }
676 + if (path === '/api/admin/probes' && req.method === 'GET') return json(res, 200, { probes: adminProbes });
677 + if (path === '/api/admin/probes' && req.method === 'POST') { if (!body.probe_id || !body.region) return json(res, 422, { error: 'validation', detail: 'probe_id and region required' }); const p = { status: 'offline', last_seen: null, version: null, measurements_1h: 0, uptime_24h: 0, clock_offset_ms: 0, capabilities: [], enabled: true, ...body }; adminProbes.push(p); return json(res, 201, { ...p, key: [...Array(64)].map(() => Math.floor(rnd() * 16).toString(16)).join('') }); }
678 + m = path.match(/^\/api\/admin\/probes\/([^/]+)\/rotate-key$/);
679 + if (m && req.method === 'POST') { const p = adminProbes.find((x) => x.probe_id === m[1]); if (!p) return notFound(res); return json(res, 200, { ...p, key: [...Array(64)].map(() => Math.floor(rnd() * 16).toString(16)).join('') }); }
680 + m = path.match(/^\/api\/admin\/probes\/([^/]+)$/);
681 + if (m) { const p = adminProbes.find((x) => x.probe_id === m[1]); if (!p) return notFound(res); if (req.method === 'PATCH') Object.assign(p, body); return json(res, 200, p); }
682 + if (path === '/api/admin/config') { if (req.method === 'PUT') { const sum = Object.values(body.pressure_weights ?? {}).reduce((a, b) => a + Number(b), 0); if (Math.abs(sum - 1) > 0.001) return json(res, 422, { error: 'validation', detail: `weights sum to ${sum.toFixed(3)}, expected 1.000` }); adminConfig = { ...adminConfig, ...body, version: (adminConfig.version ?? 1) + 1 }; } return json(res, 200, adminConfig); }
683 + if (path === '/api/admin/baselines') return json(res, 200, baselines(q.get('signal_id') ?? 'ttfb_z'));
684 + if (path === '/api/admin/raw') return json(res, 200, rawTable(q.get('table') ?? 'measurements', Number(q.get('limit') ?? 200)));
685 + if (path === '/api/admin/incidents') { const st = q.get('status'); return json(res, 200, { incidents: allIncidents().filter((i) => !st || st === 'all' || (st === 'active' ? i.status !== 'resolved' : i.status === st)).map((i) => ({ ...i, review: incidentReview[i.event_id]?.review ?? 'unreviewed', note: incidentReview[i.event_id]?.note ?? null })) }); }
686 + m = path.match(/^\/api\/admin\/incidents\/([^/]+)$/);
687 + if (m && req.method === 'PATCH') { incidentReview[m[1]] = { review: body.review ?? 'unreviewed', note: body.note ?? null }; return json(res, 200, { event_id: m[1], ...incidentReview[m[1]] }); }
688 + if (path === '/api/admin/annotations') { if (req.method === 'POST') { const a = { id: `ann_${adminAnnotations.length + 1}`, author: 'admin', ...body }; adminAnnotations.unshift(a); return json(res, 201, a); } return json(res, 200, { annotations: adminAnnotations }); }
689 + if (path === '/api/admin/replay' && req.method === 'POST') { const from = new Date(body.from ?? now() - 86400_000).getTime(); const to = new Date(body.to ?? now()).getTime(); const step = Math.max(60, Math.round((to - from) / 240 / 60_000) * 60); const w = body.weights ?? WEIGHTS; const factor = (w.routing ?? 0.25) / 0.25 * 0.6 + (w.latency ?? 0.2) / 0.2 * 0.4; const points = []; for (let t = from; t <= to; t += step * 1000) { const o = pressureAt(t); points.push({ ts: iso(t), pressure_original: o, pressure_replayed: Number(clamp(o * factor).toFixed(1)) }); } return json(res, 200, { step_seconds: step, points }); }
690 + if (path === '/api/admin/boost' && req.method === 'POST') return json(res, 200, { ok: true, targets: body.targets ?? [], factor: body.factor ?? 0.5, seconds: body.seconds ?? 900, until: iso(now() + (body.seconds ?? 900) * 1000) });
691 + return notFound(res);
692 + }
693 +
694 + // ---- public
695 + if (path === '/api/v1/status') return json(res, 200, { ok: true, ts: iso(now()), internal_status: internalStatus(), engine: { last_run: engineTs(), cycle_ms: 412, cycle_seconds: 10 }, ingest: { last_batch: minutesAgo(0), batches_5m: 312, measurements_5m: 41200 }, probes: { fresh: DEGRADED ? 1 : 8, total: 8, excluded: [] }, bgp: { fresh: !DEGRADED, last_message: minutesAgo(DEGRADED ? 14 : 0), collectors: 12 }, stores: { clickhouse: true, postgres: true, redis: true }, version: '0.1.0' });
696 + if (path === '/api/v1/pressure/global') return json(res, 200, globalPressure());
697 + if (path === '/api/v1/pressure/history') { const st = q.get('scope_type') ?? 'global'; const range = q.get('range') ?? '24h'; if (!STEP[range]) return json(res, 422, { error: 'validation', detail: 'bad range' }); return json(res, 200, history(st, q.get('scope_id'), range), RANGE_S[range] >= 86400 ? { 'Cache-Control': 'public, max-age=30' } : {}); }
698 + if (path === '/api/v1/pressure/regions') return json(res, 200, { ts: engineTs(), regions: regionsList() });
699 + let m = path.match(/^\/api\/v1\/pressure\/region\/([^/]+)$/);
700 + if (m) return REGION_PRESSURE[m[1]] === undefined ? notFound(res) : json(res, 200, regionDetail(m[1]));
701 + if (path === '/api/v1/pressure/countries') return json(res, 200, { ts: engineTs(), countries: countriesList() });
702 + m = path.match(/^\/api\/v1\/pressure\/country\/([^/]+)$/);
703 + if (m) { const cc = m[1].toUpperCase(); return COUNTRY_PRESSURE[cc] === undefined ? notFound(res) : json(res, 200, countryDetail(cc)); }
704 + if (path === '/api/v1/asns') return json(res, 200, { ts: engineTs(), asns: ASNS.map((a) => ({ asn: a.asn, name: a.name.replace(/,? (Inc\.|LLC|S\.a\.s\.|SAS|B\.V\.|AG|Ltee)$/i, ''), country: a.country, pressure: a.pressure, level: levelOf(a.pressure).id, routing: Number(clamp(a.pressure * 0.7 * (a.asn === 6453 ? 4.9 : 1.1)).toFixed(1)), latency: Number(clamp(a.pressure * 1.2).toFixed(1)), availability: Number(clamp(a.pressure * 0.5).toFixed(1)), targets: TARGETS.filter((t) => t.asn === a.asn).length, prefixes_observed: a.prefixes_observed, importance: a.importance })) });
705 + m = path.match(/^\/api\/v1\/pressure\/asn\/(\d+)$/);
706 + if (m) { const a = asnById[Number(m[1])]; return a ? json(res, 200, asnDetail(a)) : notFound(res); }
707 + if (path === '/api/v1/services') return json(res, 200, { services: SERVICES.map(serviceObj) });
708 + m = path.match(/^\/api\/v1\/service\/([^/]+)$/);
709 + if (m) { const s = SERVICES.find((x) => x[0] === m[1]); return s ? json(res, 200, serviceDetail(s)) : notFound(res); }
710 + if (path === '/api/v1/targets') return json(res, 200, { targets: TARGETS.map(({ asn, ...t }) => t) });
711 + m = path.match(/^\/api\/v1\/target\/([^/]+)$/);
712 + if (m) { const t = targetById[decodeURIComponent(m[1])]; return t ? json(res, 200, targetDetail(t)) : notFound(res); }
713 + if (path === '/api/v1/probes') return json(res, 200, { probes: probeList() });
714 + if (path === '/api/v1/incidents') { const st = q.get('status') ?? 'active'; const limit = Number(q.get('limit') ?? 50); const offset = Number(q.get('offset') ?? 0); const list = st === 'all' ? allIncidents() : st === 'resolved' ? RESOLVED : activeIncidents(); return json(res, 200, { total: list.length, incidents: list.slice(offset, offset + limit) }); }
715 + m = path.match(/^\/api\/v1\/incident\/([^/]+)$/);
716 + if (m) { const inc = allIncidents().find((i) => i.slug === m[1]); return inc ? json(res, 200, incidentDetail(inc)) : notFound(res); }
717 + if (path === '/api/v1/fronts') return json(res, 200, { ts: engineTs(), fronts: fronts() });
718 + if (path === '/api/v1/bgp/stats') return json(res, 200, bgpStats());
719 + if (path === '/api/v1/latency') return json(res, 200, latency());
720 + if (path === '/api/v1/ticker') return json(res, 200, ticker());
721 + if (path === '/api/v1/routes/pairs') return json(res, 200, { pairs: PAIRS.map(([probe_id, target_id, changed_24h, stable]) => ({ probe_id, target_id, changed_24h, current_route_hash: hash(`${probe_id}|${target_id}|${changed_24h ? 'cur' : 'base'}`), stable })) });
722 + if (path === '/api/v1/routes') { const p = q.get('probe'); const t = q.get('target'); if (!p || !t) return json(res, 422, { error: 'validation', detail: 'probe and target required' }); if (!PROBES.some((x) => x.probe_id === p) || !targetById[t]) return notFound(res); return json(res, 200, routes(p, t)); }
723 + if (path === '/api/v1/history/summary') { const y = q.get('year') ? Number(q.get('year')) : null; const mo = q.get('month') ? Number(q.get('month')) : null; return json(res, 200, historySummary(y, mo), { 'Cache-Control': 'public, max-age=30' }); }
724 + if (path === '/api/v1/explain') return json(res, 200, explain());
725 + if (path === '/api/v1/methodology') return json(res, 200, METHODOLOGY);
726 + if (path === '/api/v1/search') return json(res, 200, { results: search(q.get('q') ?? '') });
727 + if (path === '/health') return json(res, 200, { ok: true });
728 + return notFound(res);
729 +});
730 +
731 +server.listen(PORT, '127.0.0.1', () => {
732 + console.log(`[mock] InternetPressure API fixtures on http://127.0.0.1:${PORT} (${TARGETS_TOTAL} targets, ${PROBES.length} probes, ${REGION_DEFS.length} regions${DEGRADED ? ', DEGRADED mode' : ''})`);
733 + console.log(`[mock] admin token: ${ADMIN_TOKEN}`);
734 +});
added apps/web/next.config.ts +60 −0
@@ -0,0 +1,60 @@
1 +import type { NextConfig } from 'next';
2 +import { existsSync } from 'node:fs';
3 +import path from 'node:path';
4 +
5 +// Monorepo: a single `.env` lives at the repository root; Next only reads the app directory.
6 +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) {
7 + if (existsSync(candidate)) {
8 + try {
9 + process.loadEnvFile(candidate);
10 + } catch {
11 + /* ignore malformed env */
12 + }
13 + }
14 +}
15 +
16 +// Browser code always calls relative `/api/...`; in dev Next proxies it to the FastAPI service (or the mock).
17 +// In production the edge Caddy routes `/api/*` straight to the API, so this rewrite is a fallback only.
18 +const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8352';
19 +
20 +const nextConfig: NextConfig = {
21 + reactStrictMode: true,
22 + poweredByHeader: false,
23 + output: 'standalone',
24 + allowedDevOrigins: ['127.0.0.1', 'localhost'],
25 + outputFileTracingRoot: path.resolve(__dirname, '../..'),
26 + experimental: {
27 + optimizePackageImports: ['lucide-react'],
28 + },
29 + async rewrites() {
30 + return [{ source: '/api/:path*', destination: `${API_URL}/api/:path*` }];
31 + },
32 + async headers() {
33 + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=60, stale-while-revalidate=300' };
34 + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' };
35 + return [
36 + {
37 + source: '/(.*)',
38 + headers: [
39 + { key: 'X-Content-Type-Options', value: 'nosniff' },
40 + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
41 + { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
42 + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
43 + { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains' },
44 + ],
45 + },
46 + { source: '/country/:path*', headers: [PUBLIC_CACHE] },
47 + { source: '/internet/:path*', headers: [PUBLIC_CACHE] },
48 + { source: '/asn/:path*', headers: [PUBLIC_CACHE] },
49 + { source: '/service/:path*', headers: [PUBLIC_CACHE] },
50 + { source: '/event/:path*', headers: [PUBLIC_CACHE] },
51 + { source: '/history/:path*', headers: [PUBLIC_CACHE] },
52 + { source: '/history', headers: [PUBLIC_CACHE] },
53 + { source: '/api/:path*', headers: [NO_STORE] },
54 + { source: '/admin/:path*', headers: [NO_STORE] },
55 + { source: '/admin', headers: [NO_STORE] },
56 + ];
57 + },
58 +};
59 +
60 +export default nextConfig;
added apps/web/package.json +41 −0
@@ -0,0 +1,41 @@
1 +{
2 + "name": "@internetpressure/web",
3 + "version": "0.1.0",
4 + "private": true,
5 + "scripts": {
6 + "predev": "node scripts/copy-maplibre-worker.mjs",
7 + "dev": "next dev -p 8351",
8 + "prebuild": "node scripts/copy-maplibre-worker.mjs",
9 + "build": "next build",
10 + "start": "next start -p 8351 -H 0.0.0.0",
11 + "typecheck": "tsc -p tsconfig.json --noEmit",
12 + "lint": "eslint src",
13 + "mock": "node mock/server.mjs",
14 + "qa": "node qa/screens.mjs"
15 + },
16 + "dependencies": {
17 + "echarts": "^6.1.0",
18 + "geist": "^1.7.2",
19 + "lucide-react": "^1.45.0",
20 + "maplibre-gl": "^6.9.0",
21 + "next": "16.3.4",
22 + "react": "19.2.8",
23 + "react-dom": "19.2.8",
24 + "server-only": "^0.0.1",
25 + "topojson-client": "^3.1.0",
26 + "world-atlas": "^2.0.2"
27 + },
28 + "devDependencies": {
29 + "@tailwindcss/postcss": "^4",
30 + "@types/geojson": "^7946.0.16",
31 + "@types/node": "^24.0.0",
32 + "@types/react": "^19",
33 + "@types/react-dom": "^19",
34 + "@types/topojson-client": "^3.1.5",
35 + "@types/topojson-specification": "^1.0.5",
36 + "eslint": "^9",
37 + "eslint-config-next": "16.3.4",
38 + "tailwindcss": "^4",
39 + "typescript": "^5.9.3"
40 + }
41 +}
added apps/web/pnpm-lock.yaml +4459 −0
@@ -0,0 +1,4459 @@
1 +lockfileVersion: '9.0'
2 +
3 +settings:
4 + autoInstallPeers: true
5 + excludeLinksFromLockfile: false
6 +
7 +importers:
8 +
9 + .:
10 + dependencies:
11 + echarts:
12 + specifier: ^6.1.0
13 + version: 6.1.0
14 + geist:
15 + specifier: ^1.7.2
16 + version: 1.7.2(next@16.3.4(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))
17 + lucide-react:
18 + specifier: ^1.45.0
19 + version: 1.45.0(react@19.2.8)
20 + maplibre-gl:
21 + specifier: ^6.9.0
22 + version: 6.9.0
23 + next:
24 + specifier: 16.3.4
25 + version: 16.3.4(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
26 + react:
27 + specifier: 19.2.8
28 + version: 19.2.8
29 + react-dom:
30 + specifier: 19.2.8
31 + version: 19.2.8(react@19.2.8)
32 + server-only:
33 + specifier: ^0.0.1
34 + version: 0.0.1
35 + topojson-client:
36 + specifier: ^3.1.0
37 + version: 3.1.0
38 + world-atlas:
39 + specifier: ^2.0.2
40 + version: 2.0.2
41 + devDependencies:
42 + '@tailwindcss/postcss':
43 + specifier: ^4
44 + version: 4.3.3
45 + '@types/geojson':
46 + specifier: ^7946.0.16
47 + version: 7946.0.16
48 + '@types/node':
49 + specifier: ^24.0.0
50 + version: 24.13.4
51 + '@types/react':
52 + specifier: ^19
53 + version: 19.3.0
54 + '@types/react-dom':
55 + specifier: ^19
56 + version: 19.3.0(@types/react@19.3.0)
57 + '@types/topojson-client':
58 + specifier: ^3.1.5
59 + version: 3.1.5
60 + '@types/topojson-specification':
61 + specifier: ^1.0.5
62 + version: 1.0.5
63 + eslint:
64 + specifier: ^9
65 + version: 9.39.5(jiti@2.7.0)
66 + eslint-config-next:
67 + specifier: 16.3.4
68 + version: 16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
69 + tailwindcss:
70 + specifier: ^4
71 + version: 4.3.3
72 + typescript:
73 + specifier: ^5.9.3
74 + version: 5.9.3
75 +
76 +packages:
77 +
78 + '@alloc/quick-lru@5.3.0':
79 + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
80 + engines: {node: '>=10'}
81 +
82 + '@babel/code-frame@7.29.7':
83 + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
84 + engines: {node: '>=6.9.0'}
85 +
86 + '@babel/compat-data@7.29.7':
87 + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
88 + engines: {node: '>=6.9.0'}
89 +
90 + '@babel/core@7.29.7':
91 + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
92 + engines: {node: '>=6.9.0'}
93 +
94 + '@babel/generator@7.29.8':
95 + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
96 + engines: {node: '>=6.9.0'}
97 +
98 + '@babel/helper-compilation-targets@7.29.7':
99 + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
100 + engines: {node: '>=6.9.0'}
101 +
102 + '@babel/helper-globals@7.29.7':
103 + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
104 + engines: {node: '>=6.9.0'}
105 +
106 + '@babel/helper-module-imports@7.29.7':
107 + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
108 + engines: {node: '>=6.9.0'}
109 +
110 + '@babel/helper-module-transforms@7.29.7':
111 + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
112 + engines: {node: '>=6.9.0'}
113 + peerDependencies:
114 + '@babel/core': ^7.0.0
115 +
116 + '@babel/helper-string-parser@7.29.7':
117 + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
118 + engines: {node: '>=6.9.0'}
119 +
120 + '@babel/helper-validator-identifier@7.29.7':
121 + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
122 + engines: {node: '>=6.9.0'}
123 +
124 + '@babel/helper-validator-option@7.29.7':
125 + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
126 + engines: {node: '>=6.9.0'}
127 +
128 + '@babel/helpers@7.29.7':
129 + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==}
130 + engines: {node: '>=6.9.0'}
131 +
132 + '@babel/parser@7.29.8':
133 + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
134 + engines: {node: '>=6.0.0'}
135 + hasBin: true
136 +
137 + '@babel/template@7.29.7':
138 + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
139 + engines: {node: '>=6.9.0'}
140 +
141 + '@babel/traverse@7.29.8':
142 + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
143 + engines: {node: '>=6.9.0'}
144 +
145 + '@babel/types@7.29.8':
146 + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
147 + engines: {node: '>=6.9.0'}
148 +
149 + '@emnapi/core@1.10.0':
150 + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
151 +
152 + '@emnapi/runtime@1.10.0':
153 + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
154 +
155 + '@emnapi/runtime@1.11.3':
156 + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
157 +
158 + '@emnapi/wasi-threads@1.2.1':
159 + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
160 +
161 + '@eslint-community/eslint-utils@4.10.1':
162 + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
163 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
164 + peerDependencies:
165 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
166 +
167 + '@eslint-community/eslint-utils@4.9.1':
168 + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
169 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
170 + peerDependencies:
171 + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
172 +
173 + '@eslint-community/regexpp@4.12.2':
174 + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
175 + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
176 +
177 + '@eslint/config-array@0.21.2':
178 + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==}
179 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
180 +
181 + '@eslint/config-helpers@0.4.2':
182 + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
183 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
184 +
185 + '@eslint/core@0.17.0':
186 + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
187 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
188 +
189 + '@eslint/eslintrc@3.3.7':
190 + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==}
191 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
192 +
193 + '@eslint/js@9.39.5':
194 + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==}
195 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
196 +
197 + '@eslint/object-schema@2.1.7':
198 + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==}
199 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
200 +
201 + '@eslint/plugin-kit@0.4.1':
202 + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
203 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
204 +
205 + '@humanfs/core@0.19.2':
206 + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
207 + engines: {node: '>=18.18.0'}
208 +
209 + '@humanfs/node@0.16.8':
210 + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
211 + engines: {node: '>=18.18.0'}
212 +
213 + '@humanfs/types@0.15.0':
214 + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
215 + engines: {node: '>=18.18.0'}
216 +
217 + '@humanwhocodes/module-importer@1.0.1':
218 + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
219 + engines: {node: '>=12.22'}
220 +
221 + '@humanwhocodes/retry@0.4.3':
222 + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
223 + engines: {node: '>=18.18'}
224 +
225 + '@img/colour@1.1.0':
226 + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
227 + engines: {node: '>=18'}
228 +
229 + '@img/sharp-darwin-arm64@0.35.4':
230 + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
231 + engines: {node: '>=20.9.0'}
232 + cpu: [arm64]
233 + os: [darwin]
234 +
235 + '@img/sharp-darwin-x64@0.35.4':
236 + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
237 + engines: {node: '>=20.9.0'}
238 + cpu: [x64]
239 + os: [darwin]
240 +
241 + '@img/sharp-freebsd-wasm32@0.35.4':
242 + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
243 + engines: {node: '>=20.9.0'}
244 + os: [freebsd]
245 +
246 + '@img/sharp-libvips-darwin-arm64@1.3.3':
247 + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
248 + cpu: [arm64]
249 + os: [darwin]
250 +
251 + '@img/sharp-libvips-darwin-x64@1.3.3':
252 + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
253 + cpu: [x64]
254 + os: [darwin]
255 +
256 + '@img/sharp-libvips-linux-arm64@1.3.3':
257 + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
258 + cpu: [arm64]
259 + os: [linux]
260 + libc: [glibc]
261 +
262 + '@img/sharp-libvips-linux-arm@1.3.3':
263 + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
264 + cpu: [arm]
265 + os: [linux]
266 + libc: [glibc]
267 +
268 + '@img/sharp-libvips-linux-ppc64@1.3.3':
269 + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
270 + cpu: [ppc64]
271 + os: [linux]
272 + libc: [glibc]
273 +
274 + '@img/sharp-libvips-linux-riscv64@1.3.3':
275 + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
276 + cpu: [riscv64]
277 + os: [linux]
278 + libc: [glibc]
279 +
280 + '@img/sharp-libvips-linux-s390x@1.3.3':
281 + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
282 + cpu: [s390x]
283 + os: [linux]
284 + libc: [glibc]
285 +
286 + '@img/sharp-libvips-linux-x64@1.3.3':
287 + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
288 + cpu: [x64]
289 + os: [linux]
290 + libc: [glibc]
291 +
292 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
293 + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
294 + cpu: [arm64]
295 + os: [linux]
296 + libc: [musl]
297 +
298 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
299 + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
300 + cpu: [x64]
301 + os: [linux]
302 + libc: [musl]
303 +
304 + '@img/sharp-linux-arm64@0.35.4':
305 + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
306 + engines: {node: '>=20.9.0'}
307 + cpu: [arm64]
308 + os: [linux]
309 + libc: [glibc]
310 +
311 + '@img/sharp-linux-arm@0.35.4':
312 + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
313 + engines: {node: '>=20.9.0'}
314 + cpu: [arm]
315 + os: [linux]
316 + libc: [glibc]
317 +
318 + '@img/sharp-linux-ppc64@0.35.4':
319 + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
320 + engines: {node: '>=20.9.0'}
321 + cpu: [ppc64]
322 + os: [linux]
323 + libc: [glibc]
324 +
325 + '@img/sharp-linux-riscv64@0.35.4':
326 + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
327 + engines: {node: '>=20.9.0'}
328 + cpu: [riscv64]
329 + os: [linux]
330 + libc: [glibc]
331 +
332 + '@img/sharp-linux-s390x@0.35.4':
333 + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
334 + engines: {node: '>=20.9.0'}
335 + cpu: [s390x]
336 + os: [linux]
337 + libc: [glibc]
338 +
339 + '@img/sharp-linux-x64@0.35.4':
340 + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
341 + engines: {node: '>=20.9.0'}
342 + cpu: [x64]
343 + os: [linux]
344 + libc: [glibc]
345 +
346 + '@img/sharp-linuxmusl-arm64@0.35.4':
347 + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
348 + engines: {node: '>=20.9.0'}
349 + cpu: [arm64]
350 + os: [linux]
351 + libc: [musl]
352 +
353 + '@img/sharp-linuxmusl-x64@0.35.4':
354 + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
355 + engines: {node: '>=20.9.0'}
356 + cpu: [x64]
357 + os: [linux]
358 + libc: [musl]
359 +
360 + '@img/sharp-wasm32@0.35.4':
361 + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
362 + engines: {node: '>=20.9.0'}
363 +
364 + '@img/sharp-webcontainers-wasm32@0.35.4':
365 + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
366 + engines: {node: '>=20.9.0'}
367 + cpu: [wasm32]
368 +
369 + '@img/sharp-win32-arm64@0.35.4':
370 + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
371 + engines: {node: '>=20.9.0'}
372 + cpu: [arm64]
373 + os: [win32]
374 +
375 + '@img/sharp-win32-ia32@0.35.4':
376 + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
377 + engines: {node: ^20.9.0}
378 + cpu: [ia32]
379 + os: [win32]
380 +
381 + '@img/sharp-win32-x64@0.35.4':
382 + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
383 + engines: {node: '>=20.9.0'}
384 + cpu: [x64]
385 + os: [win32]
386 +
387 + '@jridgewell/gen-mapping@0.3.13':
388 + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
389 +
390 + '@jridgewell/remapping@2.3.5':
391 + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
392 +
393 + '@jridgewell/resolve-uri@3.1.2':
394 + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
395 + engines: {node: '>=6.0.0'}
396 +
397 + '@jridgewell/sourcemap-codec@1.6.0':
398 + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
399 +
400 + '@jridgewell/trace-mapping@0.3.31':
401 + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
402 +
403 + '@mapbox/jsonlint-lines-primitives@2.0.3':
404 + resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==}
405 + engines: {node: '>= 22'}
406 +
407 + '@mapbox/point-geometry@1.1.0':
408 + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==}
409 +
410 + '@mapbox/tiny-sdf@2.2.0':
411 + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==}
412 +
413 + '@mapbox/unitbezier@1.0.0':
414 + resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==}
415 +
416 + '@mapbox/vector-tile@3.0.0':
417 + resolution: {integrity: sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==}
418 +
419 + '@maplibre/geojson-vt@6.1.1':
420 + resolution: {integrity: sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==}
421 +
422 + '@maplibre/maplibre-gl-style-spec@26.4.2':
423 + resolution: {integrity: sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==}
424 + hasBin: true
425 +
426 + '@maplibre/mlt@1.2.1':
427 + resolution: {integrity: sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==}
428 +
429 + '@maplibre/vt-pbf@4.3.2':
430 + resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
431 +
432 + '@napi-rs/wasm-runtime@1.2.4':
433 + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
434 + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
435 + peerDependencies:
436 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
437 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
438 +
439 + '@next/env@16.3.4':
440 + resolution: {integrity: sha512-cjWZnUUa6jZq2kFaNe/ZyJdZonOZ/QoN0Zka2nz/FLOrfx14pQuM9c5RaSVkWMqgdt4ksgPAMWPyHSs/CyV48Q==}
441 +
442 + '@next/eslint-plugin-next@16.3.4':
443 + resolution: {integrity: sha512-szW9y2Aumu4z88YXfTzcFsgUAg2k64uzbtcO5L9f1AKS4w/GUKJcbFllRflROVyNPgJtGOnvNxiyp3v6b+prIA==}
444 +
445 + '@next/swc-darwin-arm64@16.3.4':
446 + resolution: {integrity: sha512-iBr3I5LZNk5/bgl5//iTgD2tcym14MX0Xo7fD//u9dYAEgGzza1y9oywluPtf74YnOswVdH1908aK9xVz7zQTw==}
447 + engines: {node: '>= 10'}
448 + cpu: [arm64]
449 + os: [darwin]
450 +
451 + '@next/swc-darwin-x64@16.3.4':
452 + resolution: {integrity: sha512-2dpiSyl2Jw/NrBPaU2MAKGSa+2MR82pJIn4Sm5Rjr+gxAeuh0z158Su3Z2O8zn7UNNq+ej4bToed6RcRN/Lydg==}
453 + engines: {node: '>= 10'}
454 + cpu: [x64]
455 + os: [darwin]
456 +
457 + '@next/swc-linux-arm64-gnu@16.3.4':
458 + resolution: {integrity: sha512-+t+U8HZT+fApePCS5h89CSH3datz29MkzyfCn+6fpsZBG/oiEOhINcb9rtkv6sdpToLGFn2e6146NzaKCXkqrA==}
459 + engines: {node: '>= 10'}
460 + cpu: [arm64]
461 + os: [linux]
462 + libc: [glibc]
463 +
464 + '@next/swc-linux-arm64-musl@16.3.4':
465 + resolution: {integrity: sha512-mx03GNs1ocQA5JQ4FxDMmIsNkdrZh8cuezKCrId28e5/gIPU/l7Kcy2+vmCCzdjnnmXJy+iOAu+7K0QppO6Urg==}
466 + engines: {node: '>= 10'}
467 + cpu: [arm64]
468 + os: [linux]
469 + libc: [musl]
470 +
471 + '@next/swc-linux-x64-gnu@16.3.4':
472 + resolution: {integrity: sha512-YIhGY6fSMfha52bnVxnzc9zaVBzJg+cqQTOD8tXIBSx4fuv0pVMxQTE0PaS59YhnMOiYiG09IMwxJAf/CFm/Dw==}
473 + engines: {node: '>= 10'}
474 + cpu: [x64]
475 + os: [linux]
476 + libc: [glibc]
477 +
478 + '@next/swc-linux-x64-musl@16.3.4':
479 + resolution: {integrity: sha512-+eaaX6axpDb0yF1GCpiERe6njplvdC+nks/fKfcHu3XPGRrald8P3/X7yv7QLdjA51knnxwl9pxdIJsg+w1L+Q==}
480 + engines: {node: '>= 10'}
481 + cpu: [x64]
482 + os: [linux]
483 + libc: [musl]
484 +
485 + '@next/swc-win32-arm64-msvc@16.3.4':
486 + resolution: {integrity: sha512-0jcXW7Xs/uzICrmgV3MhDYDeRy++1CqnpDIerlPIqYO4bhzB4WNbX/aRnQclustsAyTkFKB0z6rbcjmNg5tR8A==}
487 + engines: {node: '>= 10'}
488 + cpu: [arm64]
489 + os: [win32]
490 +
491 + '@next/swc-win32-x64-msvc@16.3.4':
492 + resolution: {integrity: sha512-vvBzwu1pYQCp92maZCFCIw/XgOTMR5tur9GjakwIo2cmwRTMKajRZZDS9+e4KsUZWKu1E007WUeAFXRRjZeuzw==}
493 + engines: {node: '>= 10'}
494 + cpu: [x64]
495 + os: [win32]
496 +
497 + '@nodelib/fs.scandir@2.1.5':
498 + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
499 + engines: {node: '>= 8'}
500 +
501 + '@nodelib/fs.stat@2.0.5':
502 + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
503 + engines: {node: '>= 8'}
504 +
505 + '@nodelib/fs.walk@1.2.8':
506 + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
507 + engines: {node: '>= 8'}
508 +
509 + '@nolyfill/is-core-module@1.0.39':
510 + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
511 + engines: {node: '>=12.4.0'}
512 +
513 + '@rtsao/scc@1.1.0':
514 + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
515 +
516 + '@swc/helpers@0.5.23':
517 + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
518 +
519 + '@tailwindcss/node@4.3.3':
520 + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
521 +
522 + '@tailwindcss/oxide-android-arm64@4.3.3':
523 + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
524 + engines: {node: '>= 20'}
525 + cpu: [arm64]
526 + os: [android]
527 +
528 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
529 + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
530 + engines: {node: '>= 20'}
531 + cpu: [arm64]
532 + os: [darwin]
533 +
534 + '@tailwindcss/oxide-darwin-x64@4.3.3':
535 + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
536 + engines: {node: '>= 20'}
537 + cpu: [x64]
538 + os: [darwin]
539 +
540 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
541 + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
542 + engines: {node: '>= 20'}
543 + cpu: [x64]
544 + os: [freebsd]
545 +
546 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
547 + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
548 + engines: {node: '>= 20'}
549 + cpu: [arm]
550 + os: [linux]
551 +
552 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
553 + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
554 + engines: {node: '>= 20'}
555 + cpu: [arm64]
556 + os: [linux]
557 + libc: [glibc]
558 +
559 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
560 + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
561 + engines: {node: '>= 20'}
562 + cpu: [arm64]
563 + os: [linux]
564 + libc: [musl]
565 +
566 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
567 + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
568 + engines: {node: '>= 20'}
569 + cpu: [x64]
570 + os: [linux]
571 + libc: [glibc]
572 +
573 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
574 + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
575 + engines: {node: '>= 20'}
576 + cpu: [x64]
577 + os: [linux]
578 + libc: [musl]
579 +
580 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
581 + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
582 + engines: {node: '>=14.0.0'}
583 + cpu: [wasm32]
584 + bundledDependencies:
585 + - '@napi-rs/wasm-runtime'
586 + - '@emnapi/core'
587 + - '@emnapi/runtime'
588 + - '@tybys/wasm-util'
589 + - '@emnapi/wasi-threads'
590 + - tslib
591 +
592 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
593 + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
594 + engines: {node: '>= 20'}
595 + cpu: [arm64]
596 + os: [win32]
597 +
598 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
599 + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
600 + engines: {node: '>= 20'}
601 + cpu: [x64]
602 + os: [win32]
603 +
604 + '@tailwindcss/oxide@4.3.3':
605 + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
606 + engines: {node: '>= 20'}
607 +
608 + '@tailwindcss/postcss@4.3.3':
609 + resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
610 +
611 + '@tybys/wasm-util@0.10.3':
612 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
613 +
614 + '@types/estree@1.0.9':
615 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
616 +
617 + '@types/geojson@7946.0.16':
618 + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
619 +
620 + '@types/json-schema@7.0.15':
621 + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
622 +
623 + '@types/json5@0.0.29':
624 + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
625 +
626 + '@types/node@24.13.4':
627 + resolution: {integrity: sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==}
628 +
629 + '@types/react-dom@19.3.0':
630 + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
631 + peerDependencies:
632 + '@types/react': ^19.3.0
633 +
634 + '@types/react@19.3.0':
635 + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
636 +
637 + '@types/topojson-client@3.1.5':
638 + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==}
639 +
640 + '@types/topojson-specification@1.0.5':
641 + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==}
642 +
643 + '@typescript-eslint/eslint-plugin@8.70.0':
644 + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==}
645 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
646 + peerDependencies:
647 + '@typescript-eslint/parser': ^8.70.0
648 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
649 + typescript: '>=4.8.4 <6.1.0'
650 +
651 + '@typescript-eslint/parser@8.70.0':
652 + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
653 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
654 + peerDependencies:
655 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
656 + typescript: '>=4.8.4 <6.1.0'
657 +
658 + '@typescript-eslint/project-service@8.70.0':
659 + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
660 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
661 + peerDependencies:
662 + typescript: '>=4.8.4 <6.1.0'
663 +
664 + '@typescript-eslint/scope-manager@8.70.0':
665 + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
666 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
667 +
668 + '@typescript-eslint/tsconfig-utils@8.70.0':
669 + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
670 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
671 + peerDependencies:
672 + typescript: '>=4.8.4 <6.1.0'
673 +
674 + '@typescript-eslint/type-utils@8.70.0':
675 + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==}
676 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
677 + peerDependencies:
678 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
679 + typescript: '>=4.8.4 <6.1.0'
680 +
681 + '@typescript-eslint/types@8.70.0':
682 + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
683 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
684 +
685 + '@typescript-eslint/typescript-estree@8.70.0':
686 + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
687 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
688 + peerDependencies:
689 + typescript: '>=4.8.4 <6.1.0'
690 +
691 + '@typescript-eslint/utils@8.70.0':
692 + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==}
693 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
694 + peerDependencies:
695 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
696 + typescript: '>=4.8.4 <6.1.0'
697 +
698 + '@typescript-eslint/visitor-keys@8.70.0':
699 + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
700 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
701 +
702 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
703 + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==}
704 + cpu: [arm]
705 + os: [android]
706 +
707 + '@unrs/resolver-binding-android-arm64@1.12.2':
708 + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==}
709 + cpu: [arm64]
710 + os: [android]
711 +
712 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
713 + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==}
714 + cpu: [arm64]
715 + os: [darwin]
716 +
717 + '@unrs/resolver-binding-darwin-x64@1.12.2':
718 + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==}
719 + cpu: [x64]
720 + os: [darwin]
721 +
722 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
723 + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==}
724 + cpu: [x64]
725 + os: [freebsd]
726 +
727 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
728 + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==}
729 + cpu: [arm]
730 + os: [linux]
731 +
732 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
733 + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==}
734 + cpu: [arm]
735 + os: [linux]
736 +
737 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
738 + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
739 + cpu: [arm64]
740 + os: [linux]
741 + libc: [glibc]
742 +
743 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
744 + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
745 + cpu: [arm64]
746 + os: [linux]
747 + libc: [musl]
748 +
749 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
750 + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
751 + cpu: [loong64]
752 + os: [linux]
753 + libc: [glibc]
754 +
755 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
756 + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
757 + cpu: [loong64]
758 + os: [linux]
759 + libc: [musl]
760 +
761 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
762 + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
763 + cpu: [ppc64]
764 + os: [linux]
765 + libc: [glibc]
766 +
767 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
768 + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
769 + cpu: [riscv64]
770 + os: [linux]
771 + libc: [glibc]
772 +
773 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
774 + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
775 + cpu: [riscv64]
776 + os: [linux]
777 + libc: [musl]
778 +
779 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
780 + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
781 + cpu: [s390x]
782 + os: [linux]
783 + libc: [glibc]
784 +
785 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
786 + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
787 + cpu: [x64]
788 + os: [linux]
789 + libc: [glibc]
790 +
791 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
792 + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
793 + cpu: [x64]
794 + os: [linux]
795 + libc: [musl]
796 +
797 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
798 + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
799 + cpu: [arm64]
800 + os: [openharmony]
801 +
802 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
803 + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==}
804 + engines: {node: '>=14.0.0'}
805 + cpu: [wasm32]
806 +
807 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
808 + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==}
809 + cpu: [arm64]
810 + os: [win32]
811 +
812 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
813 + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==}
814 + cpu: [ia32]
815 + os: [win32]
816 +
817 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
818 + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==}
819 + cpu: [x64]
820 + os: [win32]
821 +
822 + acorn-jsx@5.3.2:
823 + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
824 + peerDependencies:
825 + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
826 +
827 + acorn@8.18.0:
828 + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
829 + engines: {node: '>=0.4.0'}
830 + hasBin: true
831 +
832 + ajv@6.15.0:
833 + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
834 +
835 + ansi-styles@4.3.0:
836 + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
837 + engines: {node: '>=8'}
838 +
839 + argparse@2.0.1:
840 + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
841 +
842 + aria-query@5.3.2:
843 + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
844 + engines: {node: '>= 0.4'}
845 +
846 + array-buffer-byte-length@1.0.2:
847 + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
848 + engines: {node: '>= 0.4'}
849 +
850 + array-includes@3.2.0:
851 + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==}
852 + engines: {node: '>= 0.4'}
853 +
854 + array.prototype.findlast@1.2.5:
855 + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==}
856 + engines: {node: '>= 0.4'}
857 +
858 + array.prototype.findlastindex@1.2.6:
859 + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==}
860 + engines: {node: '>= 0.4'}
861 +
862 + array.prototype.flat@1.3.3:
863 + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==}
864 + engines: {node: '>= 0.4'}
865 +
866 + array.prototype.flatmap@1.3.3:
867 + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==}
868 + engines: {node: '>= 0.4'}
869 +
870 + array.prototype.tosorted@1.1.4:
871 + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==}
872 + engines: {node: '>= 0.4'}
873 +
874 + arraybuffer.prototype.slice@1.0.4:
875 + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
876 + engines: {node: '>= 0.4'}
877 +
878 + ast-types-flow@0.0.8:
879 + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
880 +
881 + async-function@1.0.0:
882 + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
883 + engines: {node: '>= 0.4'}
884 +
885 + available-typed-arrays@1.0.7:
886 + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
887 + engines: {node: '>= 0.4'}
888 +
889 + axe-core@4.13.0:
890 + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
891 + engines: {node: '>=4'}
892 +
893 + axobject-query@4.1.0:
894 + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
895 + engines: {node: '>= 0.4'}
896 +
897 + balanced-match@1.0.2:
898 + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
899 +
900 + balanced-match@4.0.4:
901 + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
902 + engines: {node: 18 || 20 || >=22}
903 +
904 + baseline-browser-mapping@2.11.22:
905 + resolution: {integrity: sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==}
906 + engines: {node: '>=6.0.0'}
907 + hasBin: true
908 +
909 + bidi-js@1.1.0:
910 + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==}
911 +
912 + brace-expansion@1.1.18:
913 + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==}
914 +
915 + brace-expansion@5.0.9:
916 + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
917 + engines: {node: 20 || >=22}
918 +
919 + braces@3.0.3:
920 + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
921 + engines: {node: '>=8'}
922 +
923 + browserslist@4.28.9:
924 + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==}
925 + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
926 + hasBin: true
927 +
928 + call-bind-apply-helpers@1.0.2:
929 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
930 + engines: {node: '>= 0.4'}
931 +
932 + call-bind@1.0.9:
933 + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==}
934 + engines: {node: '>= 0.4'}
935 +
936 + call-bound@1.0.4:
937 + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
938 + engines: {node: '>= 0.4'}
939 +
940 + callsites@3.1.0:
941 + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
942 + engines: {node: '>=6'}
943 +
944 + caniuse-lite@1.0.30001810:
945 + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
946 +
947 + chalk@4.1.2:
948 + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
949 + engines: {node: '>=10'}
950 +
951 + client-only@0.0.1:
952 + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
953 +
954 + color-convert@2.0.1:
955 + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
956 + engines: {node: '>=7.0.0'}
957 +
958 + color-name@1.1.4:
959 + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
960 +
961 + commander@2.20.3:
962 + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
963 +
964 + concat-map@0.0.1:
965 + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
966 +
967 + convert-source-map@2.0.0:
968 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
969 +
970 + cross-spawn@7.0.6:
971 + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
972 + engines: {node: '>= 8'}
973 +
974 + csstype@3.2.3:
975 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
976 +
977 + damerau-levenshtein@1.0.8:
978 + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
979 +
980 + data-view-buffer@1.0.2:
981 + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
982 + engines: {node: '>= 0.4'}
983 +
984 + data-view-byte-length@1.0.2:
985 + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==}
986 + engines: {node: '>= 0.4'}
987 +
988 + data-view-byte-offset@1.0.1:
989 + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
990 + engines: {node: '>= 0.4'}
991 +
992 + debug@3.2.7:
993 + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
994 + peerDependencies:
995 + supports-color: '*'
996 + peerDependenciesMeta:
997 + supports-color:
998 + optional: true
999 +
1000 + debug@4.4.3:
1001 + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
1002 + engines: {node: '>=6.0'}
1003 + peerDependencies:
1004 + supports-color: '*'
1005 + peerDependenciesMeta:
1006 + supports-color:
1007 + optional: true
1008 +
1009 + deep-is@0.1.4:
1010 + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
1011 +
1012 + define-data-property@1.1.4:
1013 + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
1014 + engines: {node: '>= 0.4'}
1015 +
1016 + define-properties@1.2.1:
1017 + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
1018 + engines: {node: '>= 0.4'}
1019 +
1020 + detect-libc@2.1.2:
1021 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
1022 + engines: {node: '>=8'}
1023 +
1024 + doctrine@2.1.0:
1025 + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
1026 + engines: {node: '>=0.10.0'}
1027 +
1028 + dunder-proto@1.0.1:
1029 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
1030 + engines: {node: '>= 0.4'}
1031 +
1032 + earcut@3.2.3:
1033 + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==}
1034 +
1035 + echarts@6.1.0:
1036 + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
1037 +
1038 + electron-to-chromium@1.5.427:
1039 + resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==}
1040 +
1041 + emoji-regex@9.2.2:
1042 + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
1043 +
1044 + enhanced-resolve@5.24.5:
1045 + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
1046 + engines: {node: '>=10.13.0'}
1047 +
1048 + es-abstract-get@1.0.0:
1049 + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
1050 + engines: {node: '>= 0.4'}
1051 +
1052 + es-abstract@1.24.2:
1053 + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==}
1054 + engines: {node: '>= 0.4'}
1055 +
1056 + es-define-property@1.0.1:
1057 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
1058 + engines: {node: '>= 0.4'}
1059 +
1060 + es-errors@1.3.0:
1061 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
1062 + engines: {node: '>= 0.4'}
1063 +
1064 + es-iterator-helpers@1.4.0:
1065 + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
1066 + engines: {node: '>= 0.4'}
1067 +
1068 + es-object-atoms@1.1.2:
1069 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
1070 + engines: {node: '>= 0.4'}
1071 +
1072 + es-set-tostringtag@2.1.0:
1073 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
1074 + engines: {node: '>= 0.4'}
1075 +
1076 + es-shim-unscopables@1.1.0:
1077 + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==}
1078 + engines: {node: '>= 0.4'}
1079 +
1080 + es-to-primitive@1.3.4:
1081 + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
1082 + engines: {node: '>= 0.4'}
1083 +
1084 + escalade@3.2.0:
1085 + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
1086 + engines: {node: '>=6'}
1087 +
1088 + escape-string-regexp@4.0.0:
1089 + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
1090 + engines: {node: '>=10'}
1091 +
1092 + eslint-config-next@16.3.4:
1093 + resolution: {integrity: sha512-35/8RM10huEL9vlr8hUZMERMENHBrnyHN3ZZkF9efSgzGaqK34jIqry44A956//zriUhUAUW0XSkcolhrryqAA==}
1094 + peerDependencies:
1095 + eslint: '>=9.0.0'
1096 + typescript: '>=3.3.1'
1097 + peerDependenciesMeta:
1098 + typescript:
1099 + optional: true
1100 +
1101 + eslint-import-resolver-node@0.3.10:
1102 + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==}
1103 +
1104 + eslint-import-resolver-typescript@3.10.1:
1105 + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==}
1106 + engines: {node: ^14.18.0 || >=16.0.0}
1107 + peerDependencies:
1108 + eslint: '*'
1109 + eslint-plugin-import: '*'
1110 + eslint-plugin-import-x: '*'
1111 + peerDependenciesMeta:
1112 + eslint-plugin-import:
1113 + optional: true
1114 + eslint-plugin-import-x:
1115 + optional: true
1116 +
1117 + eslint-module-utils@2.14.0:
1118 + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==}
1119 + engines: {node: '>=4'}
1120 + peerDependencies:
1121 + '@typescript-eslint/parser': '*'
1122 + eslint: '*'
1123 + eslint-import-resolver-node: '*'
1124 + eslint-import-resolver-typescript: '*'
1125 + eslint-import-resolver-webpack: '*'
1126 + peerDependenciesMeta:
1127 + '@typescript-eslint/parser':
1128 + optional: true
1129 + eslint:
1130 + optional: true
1131 + eslint-import-resolver-node:
1132 + optional: true
1133 + eslint-import-resolver-typescript:
1134 + optional: true
1135 + eslint-import-resolver-webpack:
1136 + optional: true
1137 +
1138 + eslint-plugin-import@2.32.0:
1139 + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==}
1140 + engines: {node: '>=4'}
1141 + peerDependencies:
1142 + '@typescript-eslint/parser': '*'
1143 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
1144 + peerDependenciesMeta:
1145 + '@typescript-eslint/parser':
1146 + optional: true
1147 +
1148 + eslint-plugin-jsx-a11y@6.10.2:
1149 + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==}
1150 + engines: {node: '>=4.0'}
1151 + peerDependencies:
1152 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
1153 +
1154 + eslint-plugin-react-hooks@7.1.1:
1155 + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==}
1156 + engines: {node: '>=18'}
1157 + peerDependencies:
1158 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
1159 +
1160 + eslint-plugin-react@7.37.5:
1161 + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==}
1162 + engines: {node: '>=4'}
1163 + peerDependencies:
1164 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
1165 +
1166 + eslint-scope@8.4.0:
1167 + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
1168 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1169 +
1170 + eslint-visitor-keys@3.4.3:
1171 + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
1172 + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
1173 +
1174 + eslint-visitor-keys@4.2.1:
1175 + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
1176 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1177 +
1178 + eslint-visitor-keys@5.0.1:
1179 + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
1180 + engines: {node: ^20.19.0 || ^22.13.0 || >=24}
1181 +
1182 + eslint@9.39.5:
1183 + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==}
1184 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1185 + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
1186 + hasBin: true
1187 + peerDependencies:
1188 + jiti: '*'
1189 + peerDependenciesMeta:
1190 + jiti:
1191 + optional: true
1192 +
1193 + espree@10.4.0:
1194 + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
1195 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
1196 +
1197 + esquery@1.7.0:
1198 + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
1199 + engines: {node: '>=0.10'}
1200 +
1201 + esrecurse@4.3.0:
1202 + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
1203 + engines: {node: '>=4.0'}
1204 +
1205 + estraverse@5.3.0:
1206 + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
1207 + engines: {node: '>=4.0'}
1208 +
1209 + esutils@2.0.3:
1210 + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
1211 + engines: {node: '>=0.10.0'}
1212 +
1213 + fast-deep-equal@3.1.3:
1214 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
1215 +
1216 + fast-glob@3.3.1:
1217 + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
1218 + engines: {node: '>=8.6.0'}
1219 +
1220 + fast-json-stable-stringify@2.1.0:
1221 + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
1222 +
1223 + fast-levenshtein@2.0.6:
1224 + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
1225 +
1226 + fastq@1.20.3:
1227 + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
1228 +
1229 + fdir@6.5.0:
1230 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
1231 + engines: {node: '>=12.0.0'}
1232 + peerDependencies:
1233 + picomatch: ^3 || ^4
1234 + peerDependenciesMeta:
1235 + picomatch:
1236 + optional: true
1237 +
1238 + file-entry-cache@8.0.0:
1239 + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
1240 + engines: {node: '>=16.0.0'}
1241 +
1242 + fill-range@7.1.1:
1243 + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
1244 + engines: {node: '>=8'}
1245 +
1246 + find-up@5.0.0:
1247 + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
1248 + engines: {node: '>=10'}
1249 +
1250 + flat-cache@4.0.1:
1251 + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
1252 + engines: {node: '>=16'}
1253 +
1254 + flatted@3.4.4:
1255 + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
1256 +
1257 + for-each@0.3.5:
1258 + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
1259 + engines: {node: '>= 0.4'}
1260 +
1261 + function-bind@1.1.2:
1262 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
1263 +
1264 + function.prototype.name@1.2.0:
1265 + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
1266 + engines: {node: '>= 0.4'}
1267 +
1268 + functions-have-names@1.2.3:
1269 + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
1270 +
1271 + geist@1.7.2:
1272 + resolution: {integrity: sha512-Gu5lDFa3pLRyoBlBPf0QIFHVdWAnpco7fS1bJm41jyLPFoguBgiubseUN2oLXMgqZ7uxAxDoXcHMhCY/fOTTgg==}
1273 + peerDependencies:
1274 + next: '>=13.2.0'
1275 +
1276 + generator-function@2.0.1:
1277 + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
1278 + engines: {node: '>= 0.4'}
1279 +
1280 + gensync@1.0.0-beta.2:
1281 + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
1282 + engines: {node: '>=6.9.0'}
1283 +
1284 + get-intrinsic@1.3.0:
1285 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
1286 + engines: {node: '>= 0.4'}
1287 +
1288 + get-proto@1.0.1:
1289 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
1290 + engines: {node: '>= 0.4'}
1291 +
1292 + get-symbol-description@1.1.0:
1293 + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
1294 + engines: {node: '>= 0.4'}
1295 +
1296 + get-tsconfig@4.14.3:
1297 + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==}
1298 +
1299 + gl-matrix@3.4.4:
1300 + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==}
1301 +
1302 + glob-parent@5.1.2:
1303 + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
1304 + engines: {node: '>= 6'}
1305 +
1306 + glob-parent@6.0.2:
1307 + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
1308 + engines: {node: '>=10.13.0'}
1309 +
1310 + globals@14.0.0:
1311 + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
1312 + engines: {node: '>=18'}
1313 +
1314 + globals@16.4.0:
1315 + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
1316 + engines: {node: '>=18'}
1317 +
1318 + globalthis@1.0.4:
1319 + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
1320 + engines: {node: '>= 0.4'}
1321 +
1322 + gopd@1.2.0:
1323 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
1324 + engines: {node: '>= 0.4'}
1325 +
1326 + graceful-fs@4.2.11:
1327 + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
1328 +
1329 + has-bigints@1.1.0:
1330 + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
1331 + engines: {node: '>= 0.4'}
1332 +
1333 + has-flag@4.0.0:
1334 + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
1335 + engines: {node: '>=8'}
1336 +
1337 + has-property-descriptors@1.0.2:
1338 + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==}
1339 +
1340 + has-proto@1.2.0:
1341 + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==}
1342 + engines: {node: '>= 0.4'}
1343 +
1344 + has-symbols@1.1.0:
1345 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
1346 + engines: {node: '>= 0.4'}
1347 +
1348 + has-tostringtag@1.0.2:
1349 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
1350 + engines: {node: '>= 0.4'}
1351 +
1352 + hasown@2.0.4:
1353 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
1354 + engines: {node: '>= 0.4'}
1355 +
1356 + hermes-estree@0.25.1:
1357 + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
1358 +
1359 + hermes-parser@0.25.1:
1360 + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
1361 +
1362 + ignore@5.3.2:
1363 + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
1364 + engines: {node: '>= 4'}
1365 +
1366 + ignore@7.0.9:
1367 + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==}
1368 + engines: {node: '>= 4'}
1369 +
1370 + import-fresh@3.3.1:
1371 + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
1372 + engines: {node: '>=6'}
1373 +
1374 + imurmurhash@0.1.4:
1375 + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
1376 + engines: {node: '>=0.8.19'}
1377 +
1378 + internal-slot@1.1.0:
1379 + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
1380 + engines: {node: '>= 0.4'}
1381 +
1382 + is-array-buffer@3.0.5:
1383 + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
1384 + engines: {node: '>= 0.4'}
1385 +
1386 + is-async-function@2.1.1:
1387 + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
1388 + engines: {node: '>= 0.4'}
1389 +
1390 + is-bigint@1.1.0:
1391 + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==}
1392 + engines: {node: '>= 0.4'}
1393 +
1394 + is-boolean-object@1.2.2:
1395 + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
1396 + engines: {node: '>= 0.4'}
1397 +
1398 + is-bun-module@2.0.0:
1399 + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
1400 +
1401 + is-callable@1.2.7:
1402 + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
1403 + engines: {node: '>= 0.4'}
1404 +
1405 + is-core-module@2.16.2:
1406 + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
1407 + engines: {node: '>= 0.4'}
1408 +
1409 + is-data-view@1.0.2:
1410 + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==}
1411 + engines: {node: '>= 0.4'}
1412 +
1413 + is-date-object@1.1.0:
1414 + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
1415 + engines: {node: '>= 0.4'}
1416 +
1417 + is-document.all@1.0.0:
1418 + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==}
1419 + engines: {node: '>= 0.4'}
1420 +
1421 + is-extglob@2.1.1:
1422 + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
1423 + engines: {node: '>=0.10.0'}
1424 +
1425 + is-finalizationregistry@1.1.1:
1426 + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
1427 + engines: {node: '>= 0.4'}
1428 +
1429 + is-generator-function@1.1.2:
1430 + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
1431 + engines: {node: '>= 0.4'}
1432 +
1433 + is-glob@4.0.3:
1434 + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
1435 + engines: {node: '>=0.10.0'}
1436 +
1437 + is-map@2.0.3:
1438 + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
1439 + engines: {node: '>= 0.4'}
1440 +
1441 + is-negative-zero@2.0.3:
1442 + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
1443 + engines: {node: '>= 0.4'}
1444 +
1445 + is-number-object@1.1.1:
1446 + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
1447 + engines: {node: '>= 0.4'}
1448 +
1449 + is-number@7.0.0:
1450 + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
1451 + engines: {node: '>=0.12.0'}
1452 +
1453 + is-regex@1.2.1:
1454 + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
1455 + engines: {node: '>= 0.4'}
1456 +
1457 + is-set@2.0.3:
1458 + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==}
1459 + engines: {node: '>= 0.4'}
1460 +
1461 + is-shared-array-buffer@1.0.4:
1462 + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
1463 + engines: {node: '>= 0.4'}
1464 +
1465 + is-string@1.1.1:
1466 + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
1467 + engines: {node: '>= 0.4'}
1468 +
1469 + is-symbol@1.1.1:
1470 + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
1471 + engines: {node: '>= 0.4'}
1472 +
1473 + is-typed-array@1.1.15:
1474 + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==}
1475 + engines: {node: '>= 0.4'}
1476 +
1477 + is-weakmap@2.0.2:
1478 + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
1479 + engines: {node: '>= 0.4'}
1480 +
1481 + is-weakref@1.1.1:
1482 + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==}
1483 + engines: {node: '>= 0.4'}
1484 +
1485 + is-weakset@2.0.4:
1486 + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
1487 + engines: {node: '>= 0.4'}
1488 +
1489 + isarray@2.0.5:
1490 + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
1491 +
1492 + isexe@2.0.0:
1493 + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
1494 +
1495 + iterator.prototype@1.1.5:
1496 + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
1497 + engines: {node: '>= 0.4'}
1498 +
1499 + jiti@2.7.0:
1500 + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
1501 + hasBin: true
1502 +
1503 + js-tokens@4.0.0:
1504 + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
1505 +
1506 + js-yaml@4.3.2:
1507 + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
1508 + hasBin: true
1509 +
1510 + jsesc@3.1.0:
1511 + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
1512 + engines: {node: '>=6'}
1513 + hasBin: true
1514 +
1515 + json-buffer@3.0.1:
1516 + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
1517 +
1518 + json-schema-traverse@0.4.1:
1519 + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
1520 +
1521 + json-stable-stringify-without-jsonify@1.0.1:
1522 + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
1523 +
1524 + json-stringify-pretty-compact@4.0.0:
1525 + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==}
1526 +
1527 + json5@1.0.2:
1528 + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
1529 + hasBin: true
1530 +
1531 + json5@2.2.3:
1532 + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
1533 + engines: {node: '>=6'}
1534 + hasBin: true
1535 +
1536 + jsx-ast-utils@3.3.5:
1537 + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
1538 + engines: {node: '>=4.0'}
1539 +
1540 + kdbush@4.1.0:
1541 + resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==}
1542 +
1543 + keyv@4.5.4:
1544 + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
1545 +
1546 + language-subtag-registry@0.3.23:
1547 + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
1548 +
1549 + language-tags@1.0.9:
1550 + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
1551 + engines: {node: '>=0.10'}
1552 +
1553 + levn@0.4.1:
1554 + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
1555 + engines: {node: '>= 0.8.0'}
1556 +
1557 + lightningcss-android-arm64@1.32.0:
1558 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
1559 + engines: {node: '>= 12.0.0'}
1560 + cpu: [arm64]
1561 + os: [android]
1562 +
1563 + lightningcss-darwin-arm64@1.32.0:
1564 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
1565 + engines: {node: '>= 12.0.0'}
1566 + cpu: [arm64]
1567 + os: [darwin]
1568 +
1569 + lightningcss-darwin-x64@1.32.0:
1570 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
1571 + engines: {node: '>= 12.0.0'}
1572 + cpu: [x64]
1573 + os: [darwin]
1574 +
1575 + lightningcss-freebsd-x64@1.32.0:
1576 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
1577 + engines: {node: '>= 12.0.0'}
1578 + cpu: [x64]
1579 + os: [freebsd]
1580 +
1581 + lightningcss-linux-arm-gnueabihf@1.32.0:
1582 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
1583 + engines: {node: '>= 12.0.0'}
1584 + cpu: [arm]
1585 + os: [linux]
1586 +
1587 + lightningcss-linux-arm64-gnu@1.32.0:
1588 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
1589 + engines: {node: '>= 12.0.0'}
1590 + cpu: [arm64]
1591 + os: [linux]
1592 + libc: [glibc]
1593 +
1594 + lightningcss-linux-arm64-musl@1.32.0:
1595 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
1596 + engines: {node: '>= 12.0.0'}
1597 + cpu: [arm64]
1598 + os: [linux]
1599 + libc: [musl]
1600 +
1601 + lightningcss-linux-x64-gnu@1.32.0:
1602 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
1603 + engines: {node: '>= 12.0.0'}
1604 + cpu: [x64]
1605 + os: [linux]
1606 + libc: [glibc]
1607 +
1608 + lightningcss-linux-x64-musl@1.32.0:
1609 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
1610 + engines: {node: '>= 12.0.0'}
1611 + cpu: [x64]
1612 + os: [linux]
1613 + libc: [musl]
1614 +
1615 + lightningcss-win32-arm64-msvc@1.32.0:
1616 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
1617 + engines: {node: '>= 12.0.0'}
1618 + cpu: [arm64]
1619 + os: [win32]
1620 +
1621 + lightningcss-win32-x64-msvc@1.32.0:
1622 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
1623 + engines: {node: '>= 12.0.0'}
1624 + cpu: [x64]
1625 + os: [win32]
1626 +
1627 + lightningcss@1.32.0:
1628 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
1629 + engines: {node: '>= 12.0.0'}
1630 +
1631 + locate-path@6.0.0:
1632 + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
1633 + engines: {node: '>=10'}
1634 +
1635 + lodash.merge@4.6.2:
1636 + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
1637 +
1638 + loose-envify@1.4.0:
1639 + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
1640 + hasBin: true
1641 +
1642 + lru-cache@5.1.1:
1643 + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
1644 +
1645 + lucide-react@1.45.0:
1646 + resolution: {integrity: sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==}
1647 + peerDependencies:
1648 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
1649 +
1650 + magic-string@0.30.21:
1651 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
1652 +
1653 + maplibre-gl@6.9.0:
1654 + resolution: {integrity: sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==}
1655 + engines: {node: '>=16.14.0', npm: '>=8.1.0'}
1656 +
1657 + math-intrinsics@1.1.0:
1658 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
1659 + engines: {node: '>= 0.4'}
1660 +
1661 + merge2@1.4.1:
1662 + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
1663 + engines: {node: '>= 8'}
1664 +
1665 + micromatch@4.0.8:
1666 + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
1667 + engines: {node: '>=8.6'}
1668 +
1669 + minimatch@10.2.6:
1670 + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
1671 + engines: {node: 18 || 20 || >=22}
1672 +
1673 + minimatch@3.1.5:
1674 + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
1675 +
1676 + minimist@1.2.8:
1677 + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
1678 +
1679 + ms@2.1.3:
1680 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
1681 +
1682 + murmurhash-js@1.0.0:
1683 + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==}
1684 +
1685 + nanoid@3.3.19:
1686 + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
1687 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
1688 + hasBin: true
1689 +
1690 + napi-postinstall@0.3.4:
1691 + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
1692 + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
1693 + hasBin: true
1694 +
1695 + natural-compare@1.4.0:
1696 + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
1697 +
1698 + next@16.3.4:
1699 + resolution: {integrity: sha512-/Ztf6CeRH+ejEXUrYtqI4gkS66eFIHuSwqi60RgcpWKodxFZx2/dqVCMKBwILfAHXQ+F1b1vAudgj3mnxqtoIA==}
1700 + engines: {node: '>=20.9.0'}
1701 + hasBin: true
1702 + peerDependencies:
1703 + '@opentelemetry/api': ^1.1.0
1704 + '@playwright/test': ^1.51.1
1705 + babel-plugin-react-compiler: '*'
1706 + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1707 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
1708 + sass: ^1.3.0
1709 + peerDependenciesMeta:
1710 + '@opentelemetry/api':
1711 + optional: true
1712 + '@playwright/test':
1713 + optional: true
1714 + babel-plugin-react-compiler:
1715 + optional: true
1716 + sass:
1717 + optional: true
1718 +
1719 + node-exports-info@1.6.2:
1720 + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==}
1721 + engines: {node: '>= 0.4'}
1722 +
1723 + node-releases@2.0.55:
1724 + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==}
1725 + engines: {node: '>=18'}
1726 +
1727 + object-assign@4.1.1:
1728 + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
1729 + engines: {node: '>=0.10.0'}
1730 +
1731 + object-inspect@1.13.4:
1732 + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
1733 + engines: {node: '>= 0.4'}
1734 +
1735 + object-keys@1.1.1:
1736 + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
1737 + engines: {node: '>= 0.4'}
1738 +
1739 + object.assign@4.1.7:
1740 + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==}
1741 + engines: {node: '>= 0.4'}
1742 +
1743 + object.entries@1.1.9:
1744 + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==}
1745 + engines: {node: '>= 0.4'}
1746 +
1747 + object.fromentries@2.0.8:
1748 + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==}
1749 + engines: {node: '>= 0.4'}
1750 +
1751 + object.groupby@1.0.3:
1752 + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==}
1753 + engines: {node: '>= 0.4'}
1754 +
1755 + object.values@1.2.1:
1756 + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
1757 + engines: {node: '>= 0.4'}
1758 +
1759 + optionator@0.9.4:
1760 + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
1761 + engines: {node: '>= 0.8.0'}
1762 +
1763 + own-keys@1.0.2:
1764 + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==}
1765 + engines: {node: '>= 0.4'}
1766 +
1767 + p-limit@3.1.0:
1768 + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
1769 + engines: {node: '>=10'}
1770 +
1771 + p-locate@5.0.0:
1772 + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
1773 + engines: {node: '>=10'}
1774 +
1775 + parent-module@1.0.1:
1776 + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
1777 + engines: {node: '>=6'}
1778 +
1779 + path-exists@4.0.0:
1780 + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
1781 + engines: {node: '>=8'}
1782 +
1783 + path-key@3.1.1:
1784 + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
1785 + engines: {node: '>=8'}
1786 +
1787 + path-parse@1.0.7:
1788 + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
1789 +
1790 + pbf@5.1.2:
1791 + resolution: {integrity: sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==}
1792 + hasBin: true
1793 +
1794 + picocolors@1.1.1:
1795 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
1796 +
1797 + picomatch@2.3.2:
1798 + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
1799 + engines: {node: '>=8.6'}
1800 +
1801 + picomatch@4.0.7:
1802 + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
1803 + engines: {node: '>=12'}
1804 +
1805 + possible-typed-array-names@1.1.0:
1806 + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
1807 + engines: {node: '>= 0.4'}
1808 +
1809 + postcss@8.5.23:
1810 + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
1811 + engines: {node: ^10 || ^12 || >=14}
1812 +
1813 + postcss@8.5.28:
1814 + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
1815 + engines: {node: ^10 || ^12 || >=14}
1816 +
1817 + potpack@2.1.0:
1818 + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==}
1819 +
1820 + prelude-ls@1.2.1:
1821 + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
1822 + engines: {node: '>= 0.8.0'}
1823 +
1824 + prop-types@15.8.1:
1825 + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
1826 +
1827 + protocol-buffers-schema@3.6.1:
1828 + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==}
1829 +
1830 + punycode@2.3.1:
1831 + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
1832 + engines: {node: '>=6'}
1833 +
1834 + queue-microtask@1.2.3:
1835 + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
1836 +
1837 + quickselect@3.0.0:
1838 + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
1839 +
1840 + react-dom@19.2.8:
1841 + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
1842 + peerDependencies:
1843 + react: ^19.2.8
1844 +
1845 + react-is@16.13.1:
1846 + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
1847 +
1848 + react@19.2.8:
1849 + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
1850 + engines: {node: '>=0.10.0'}
1851 +
1852 + reflect.getprototypeof@1.0.10:
1853 + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
1854 + engines: {node: '>= 0.4'}
1855 +
1856 + regexp.prototype.flags@1.5.4:
1857 + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
1858 + engines: {node: '>= 0.4'}
1859 +
1860 + require-from-string@2.0.2:
1861 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
1862 + engines: {node: '>=0.10.0'}
1863 +
1864 + resolve-from@4.0.0:
1865 + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
1866 + engines: {node: '>=4'}
1867 +
1868 + resolve-pkg-maps@1.0.0:
1869 + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
1870 +
1871 + resolve-protobuf-schema@2.1.0:
1872 + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
1873 +
1874 + resolve@2.0.0-next.7:
1875 + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==}
1876 + engines: {node: '>= 0.4'}
1877 + hasBin: true
1878 +
1879 + reusify@1.1.0:
1880 + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
1881 + engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
1882 +
1883 + run-parallel@1.2.0:
1884 + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
1885 +
1886 + safe-array-concat@1.1.4:
1887 + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==}
1888 + engines: {node: '>=0.4'}
1889 +
1890 + safe-push-apply@1.0.0:
1891 + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==}
1892 + engines: {node: '>= 0.4'}
1893 +
1894 + safe-regex-test@1.1.0:
1895 + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
1896 + engines: {node: '>= 0.4'}
1897 +
1898 + scheduler@0.27.0:
1899 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
1900 +
1901 + semver@6.3.1:
1902 + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
1903 + hasBin: true
1904 +
1905 + semver@7.8.5:
1906 + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
1907 + engines: {node: '>=10'}
1908 + hasBin: true
1909 +
1910 + server-only@0.0.1:
1911 + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
1912 +
1913 + set-function-length@1.2.2:
1914 + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
1915 + engines: {node: '>= 0.4'}
1916 +
1917 + set-function-name@2.0.2:
1918 + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==}
1919 + engines: {node: '>= 0.4'}
1920 +
1921 + set-proto@1.0.0:
1922 + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==}
1923 + engines: {node: '>= 0.4'}
1924 +
1925 + sharp@0.35.4:
1926 + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
1927 + engines: {node: '>=20.9.0'}
1928 + peerDependencies:
1929 + '@types/node': '*'
1930 + peerDependenciesMeta:
1931 + '@types/node':
1932 + optional: true
1933 +
1934 + shebang-command@2.0.0:
1935 + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
1936 + engines: {node: '>=8'}
1937 +
1938 + shebang-regex@3.0.0:
1939 + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
1940 + engines: {node: '>=8'}
1941 +
1942 + side-channel-list@1.0.1:
1943 + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
1944 + engines: {node: '>= 0.4'}
1945 +
1946 + side-channel-map@1.0.1:
1947 + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
1948 + engines: {node: '>= 0.4'}
1949 +
1950 + side-channel-weakmap@1.0.2:
1951 + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
1952 + engines: {node: '>= 0.4'}
1953 +
1954 + side-channel@1.1.1:
1955 + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
1956 + engines: {node: '>= 0.4'}
1957 +
1958 + source-map-js@1.2.1:
1959 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
1960 + engines: {node: '>=0.10.0'}
1961 +
1962 + stable-hash@0.0.5:
1963 + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
1964 +
1965 + stop-iteration-iterator@1.1.0:
1966 + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
1967 + engines: {node: '>= 0.4'}
1968 +
1969 + string.prototype.includes@2.0.1:
1970 + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
1971 + engines: {node: '>= 0.4'}
1972 +
1973 + string.prototype.matchall@4.1.0:
1974 + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==}
1975 + engines: {node: '>= 0.4'}
1976 +
1977 + string.prototype.repeat@1.0.0:
1978 + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==}
1979 +
1980 + string.prototype.trim@1.2.11:
1981 + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==}
1982 + engines: {node: '>= 0.4'}
1983 +
1984 + string.prototype.trimend@1.0.10:
1985 + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==}
1986 + engines: {node: '>= 0.4'}
1987 +
1988 + string.prototype.trimstart@1.0.8:
1989 + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
1990 + engines: {node: '>= 0.4'}
1991 +
1992 + strip-bom@3.0.0:
1993 + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
1994 + engines: {node: '>=4'}
1995 +
1996 + strip-json-comments@3.1.1:
1997 + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
1998 + engines: {node: '>=8'}
1999 +
2000 + styled-jsx@5.1.6:
2001 + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
2002 + engines: {node: '>= 12.0.0'}
2003 + peerDependencies:
2004 + '@babel/core': '*'
2005 + babel-plugin-macros: '*'
2006 + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
2007 + peerDependenciesMeta:
2008 + '@babel/core':
2009 + optional: true
2010 + babel-plugin-macros:
2011 + optional: true
2012 +
2013 + supports-color@7.2.0:
2014 + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
2015 + engines: {node: '>=8'}
2016 +
2017 + supports-preserve-symlinks-flag@1.0.0:
2018 + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
2019 + engines: {node: '>= 0.4'}
2020 +
2021 + tailwindcss@4.3.3:
2022 + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
2023 +
2024 + tapable@2.3.3:
2025 + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
2026 + engines: {node: '>=6'}
2027 +
2028 + tinyglobby@0.2.17:
2029 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
2030 + engines: {node: '>=12.0.0'}
2031 +
2032 + tinyqueue@3.0.0:
2033 + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
2034 +
2035 + to-regex-range@5.0.1:
2036 + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
2037 + engines: {node: '>=8.0'}
2038 +
2039 + topojson-client@3.1.0:
2040 + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==}
2041 + hasBin: true
2042 +
2043 + ts-api-utils@2.5.0:
2044 + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
2045 + engines: {node: '>=18.12'}
2046 + peerDependencies:
2047 + typescript: '>=4.8.4'
2048 +
2049 + tsconfig-paths@3.15.0:
2050 + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
2051 +
2052 + tslib@2.3.0:
2053 + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
2054 +
2055 + tslib@2.8.1:
2056 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
2057 +
2058 + type-check@0.4.0:
2059 + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
2060 + engines: {node: '>= 0.8.0'}
2061 +
2062 + typed-array-buffer@1.0.3:
2063 + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
2064 + engines: {node: '>= 0.4'}
2065 +
2066 + typed-array-byte-length@1.0.3:
2067 + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==}
2068 + engines: {node: '>= 0.4'}
2069 +
2070 + typed-array-byte-offset@1.0.4:
2071 + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==}
2072 + engines: {node: '>= 0.4'}
2073 +
2074 + typed-array-length@1.0.8:
2075 + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
2076 + engines: {node: '>= 0.4'}
2077 +
2078 + typescript-eslint@8.70.0:
2079 + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==}
2080 + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
2081 + peerDependencies:
2082 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
2083 + typescript: '>=4.8.4 <6.1.0'
2084 +
2085 + typescript@5.9.3:
2086 + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
2087 + engines: {node: '>=14.17'}
2088 + hasBin: true
2089 +
2090 + unbox-primitive@1.1.0:
2091 + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
2092 + engines: {node: '>= 0.4'}
2093 +
2094 + undici-types@7.18.2:
2095 + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
2096 +
2097 + unrs-resolver@1.12.2:
2098 + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==}
2099 +
2100 + update-browserslist-db@1.3.3:
2101 + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==}
2102 + hasBin: true
2103 + peerDependencies:
2104 + browserslist: '>= 4.21.0'
2105 +
2106 + uri-js@4.4.1:
2107 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
2108 +
2109 + which-boxed-primitive@1.1.1:
2110 + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
2111 + engines: {node: '>= 0.4'}
2112 +
2113 + which-builtin-type@1.2.1:
2114 + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==}
2115 + engines: {node: '>= 0.4'}
2116 +
2117 + which-collection@1.0.2:
2118 + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
2119 + engines: {node: '>= 0.4'}
2120 +
2121 + which-typed-array@1.1.22:
2122 + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
2123 + engines: {node: '>= 0.4'}
2124 +
2125 + which@2.0.2:
2126 + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
2127 + engines: {node: '>= 8'}
2128 + hasBin: true
2129 +
2130 + word-wrap@1.2.5:
2131 + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
2132 + engines: {node: '>=0.10.0'}
2133 +
2134 + world-atlas@2.0.2:
2135 + resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==}
2136 +
2137 + yallist@3.1.1:
2138 + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
2139 +
2140 + yocto-queue@0.1.0:
2141 + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
2142 + engines: {node: '>=10'}
2143 +
2144 + zod-validation-error@4.0.2:
2145 + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
2146 + engines: {node: '>=18.0.0'}
2147 + peerDependencies:
2148 + zod: ^3.25.0 || ^4.0.0
2149 +
2150 + zod@4.6.2:
2151 + resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==}
2152 +
2153 + zrender@6.1.0:
2154 + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
2155 +
2156 +snapshots:
2157 +
2158 + '@alloc/quick-lru@5.3.0': {}
2159 +
2160 + '@babel/code-frame@7.29.7':
2161 + dependencies:
2162 + '@babel/helper-validator-identifier': 7.29.7
2163 + js-tokens: 4.0.0
2164 + picocolors: 1.1.1
2165 +
2166 + '@babel/compat-data@7.29.7': {}
2167 +
2168 + '@babel/core@7.29.7':
2169 + dependencies:
2170 + '@babel/code-frame': 7.29.7
2171 + '@babel/generator': 7.29.8
2172 + '@babel/helper-compilation-targets': 7.29.7
2173 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
2174 + '@babel/helpers': 7.29.7
2175 + '@babel/parser': 7.29.8
2176 + '@babel/template': 7.29.7
2177 + '@babel/traverse': 7.29.8
2178 + '@babel/types': 7.29.8
2179 + '@jridgewell/remapping': 2.3.5
2180 + convert-source-map: 2.0.0
2181 + debug: 4.4.3
2182 + gensync: 1.0.0-beta.2
2183 + json5: 2.2.3
2184 + semver: 6.3.1
2185 + transitivePeerDependencies:
2186 + - supports-color
2187 +
2188 + '@babel/generator@7.29.8':
2189 + dependencies:
2190 + '@babel/parser': 7.29.8
2191 + '@babel/types': 7.29.8
2192 + '@jridgewell/gen-mapping': 0.3.13
2193 + '@jridgewell/trace-mapping': 0.3.31
2194 + jsesc: 3.1.0
2195 +
2196 + '@babel/helper-compilation-targets@7.29.7':
2197 + dependencies:
2198 + '@babel/compat-data': 7.29.7
2199 + '@babel/helper-validator-option': 7.29.7
2200 + browserslist: 4.28.9
2201 + lru-cache: 5.1.1
2202 + semver: 6.3.1
2203 +
2204 + '@babel/helper-globals@7.29.7': {}
2205 +
2206 + '@babel/helper-module-imports@7.29.7':
2207 + dependencies:
2208 + '@babel/traverse': 7.29.8
2209 + '@babel/types': 7.29.8
2210 + transitivePeerDependencies:
2211 + - supports-color
2212 +
2213 + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
2214 + dependencies:
2215 + '@babel/core': 7.29.7
2216 + '@babel/helper-module-imports': 7.29.7
2217 + '@babel/helper-validator-identifier': 7.29.7
2218 + '@babel/traverse': 7.29.8
2219 + transitivePeerDependencies:
2220 + - supports-color
2221 +
2222 + '@babel/helper-string-parser@7.29.7': {}
2223 +
2224 + '@babel/helper-validator-identifier@7.29.7': {}
2225 +
2226 + '@babel/helper-validator-option@7.29.7': {}
2227 +
2228 + '@babel/helpers@7.29.7':
2229 + dependencies:
2230 + '@babel/template': 7.29.7
2231 + '@babel/types': 7.29.8
2232 +
2233 + '@babel/parser@7.29.8':
2234 + dependencies:
2235 + '@babel/types': 7.29.8
2236 +
2237 + '@babel/template@7.29.7':
2238 + dependencies:
2239 + '@babel/code-frame': 7.29.7
2240 + '@babel/parser': 7.29.8
2241 + '@babel/types': 7.29.8
2242 +
2243 + '@babel/traverse@7.29.8':
2244 + dependencies:
2245 + '@babel/code-frame': 7.29.7
2246 + '@babel/generator': 7.29.8
2247 + '@babel/helper-globals': 7.29.7
2248 + '@babel/parser': 7.29.8
2249 + '@babel/template': 7.29.7
2250 + '@babel/types': 7.29.8
2251 + debug: 4.4.3
2252 + transitivePeerDependencies:
2253 + - supports-color
2254 +
2255 + '@babel/types@7.29.8':
2256 + dependencies:
2257 + '@babel/helper-string-parser': 7.29.7
2258 + '@babel/helper-validator-identifier': 7.29.7
2259 +
2260 + '@emnapi/core@1.10.0':
2261 + dependencies:
2262 + '@emnapi/wasi-threads': 1.2.1
2263 + tslib: 2.8.1
2264 + optional: true
2265 +
2266 + '@emnapi/runtime@1.10.0':
2267 + dependencies:
2268 + tslib: 2.8.1
2269 + optional: true
2270 +
2271 + '@emnapi/runtime@1.11.3':
2272 + dependencies:
2273 + tslib: 2.8.1
2274 + optional: true
2275 +
2276 + '@emnapi/wasi-threads@1.2.1':
2277 + dependencies:
2278 + tslib: 2.8.1
2279 + optional: true
2280 +
2281 + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))':
2282 + dependencies:
2283 + eslint: 9.39.5(jiti@2.7.0)
2284 + eslint-visitor-keys: 3.4.3
2285 +
2286 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5(jiti@2.7.0))':
2287 + dependencies:
2288 + eslint: 9.39.5(jiti@2.7.0)
2289 + eslint-visitor-keys: 3.4.3
2290 +
2291 + '@eslint-community/regexpp@4.12.2': {}
2292 +
2293 + '@eslint/config-array@0.21.2':
2294 + dependencies:
2295 + '@eslint/object-schema': 2.1.7
2296 + debug: 4.4.3
2297 + minimatch: 3.1.5
2298 + transitivePeerDependencies:
2299 + - supports-color
2300 +
2301 + '@eslint/config-helpers@0.4.2':
2302 + dependencies:
2303 + '@eslint/core': 0.17.0
2304 +
2305 + '@eslint/core@0.17.0':
2306 + dependencies:
2307 + '@types/json-schema': 7.0.15
2308 +
2309 + '@eslint/eslintrc@3.3.7':
2310 + dependencies:
2311 + ajv: 6.15.0
2312 + debug: 4.4.3
2313 + espree: 10.4.0
2314 + globals: 14.0.0
2315 + ignore: 5.3.2
2316 + import-fresh: 3.3.1
2317 + js-yaml: 4.3.2
2318 + minimatch: 3.1.5
2319 + strip-json-comments: 3.1.1
2320 + transitivePeerDependencies:
2321 + - supports-color
2322 +
2323 + '@eslint/js@9.39.5': {}
2324 +
2325 + '@eslint/object-schema@2.1.7': {}
2326 +
2327 + '@eslint/plugin-kit@0.4.1':
2328 + dependencies:
2329 + '@eslint/core': 0.17.0
2330 + levn: 0.4.1
2331 +
2332 + '@humanfs/core@0.19.2':
2333 + dependencies:
2334 + '@humanfs/types': 0.15.0
2335 +
2336 + '@humanfs/node@0.16.8':
2337 + dependencies:
2338 + '@humanfs/core': 0.19.2
2339 + '@humanfs/types': 0.15.0
2340 + '@humanwhocodes/retry': 0.4.3
2341 +
2342 + '@humanfs/types@0.15.0': {}
2343 +
2344 + '@humanwhocodes/module-importer@1.0.1': {}
2345 +
2346 + '@humanwhocodes/retry@0.4.3': {}
2347 +
2348 + '@img/colour@1.1.0':
2349 + optional: true
2350 +
2351 + '@img/sharp-darwin-arm64@0.35.4':
2352 + optionalDependencies:
2353 + '@img/sharp-libvips-darwin-arm64': 1.3.3
2354 + optional: true
2355 +
2356 + '@img/sharp-darwin-x64@0.35.4':
2357 + optionalDependencies:
2358 + '@img/sharp-libvips-darwin-x64': 1.3.3
2359 + optional: true
2360 +
2361 + '@img/sharp-freebsd-wasm32@0.35.4':
2362 + dependencies:
2363 + '@img/sharp-wasm32': 0.35.4
2364 + optional: true
2365 +
2366 + '@img/sharp-libvips-darwin-arm64@1.3.3':
2367 + optional: true
2368 +
2369 + '@img/sharp-libvips-darwin-x64@1.3.3':
2370 + optional: true
2371 +
2372 + '@img/sharp-libvips-linux-arm64@1.3.3':
2373 + optional: true
2374 +
2375 + '@img/sharp-libvips-linux-arm@1.3.3':
2376 + optional: true
2377 +
2378 + '@img/sharp-libvips-linux-ppc64@1.3.3':
2379 + optional: true
2380 +
2381 + '@img/sharp-libvips-linux-riscv64@1.3.3':
2382 + optional: true
2383 +
2384 + '@img/sharp-libvips-linux-s390x@1.3.3':
2385 + optional: true
2386 +
2387 + '@img/sharp-libvips-linux-x64@1.3.3':
2388 + optional: true
2389 +
2390 + '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
2391 + optional: true
2392 +
2393 + '@img/sharp-libvips-linuxmusl-x64@1.3.3':
2394 + optional: true
2395 +
2396 + '@img/sharp-linux-arm64@0.35.4':
2397 + optionalDependencies:
2398 + '@img/sharp-libvips-linux-arm64': 1.3.3
2399 + optional: true
2400 +
2401 + '@img/sharp-linux-arm@0.35.4':
2402 + optionalDependencies:
2403 + '@img/sharp-libvips-linux-arm': 1.3.3
2404 + optional: true
2405 +
2406 + '@img/sharp-linux-ppc64@0.35.4':
2407 + optionalDependencies:
2408 + '@img/sharp-libvips-linux-ppc64': 1.3.3
2409 + optional: true
2410 +
2411 + '@img/sharp-linux-riscv64@0.35.4':
2412 + optionalDependencies:
2413 + '@img/sharp-libvips-linux-riscv64': 1.3.3
2414 + optional: true
2415 +
2416 + '@img/sharp-linux-s390x@0.35.4':
2417 + optionalDependencies:
2418 + '@img/sharp-libvips-linux-s390x': 1.3.3
2419 + optional: true
2420 +
2421 + '@img/sharp-linux-x64@0.35.4':
2422 + optionalDependencies:
2423 + '@img/sharp-libvips-linux-x64': 1.3.3
2424 + optional: true
2425 +
2426 + '@img/sharp-linuxmusl-arm64@0.35.4':
2427 + optionalDependencies:
2428 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
2429 + optional: true
2430 +
2431 + '@img/sharp-linuxmusl-x64@0.35.4':
2432 + optionalDependencies:
2433 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
2434 + optional: true
2435 +
2436 + '@img/sharp-wasm32@0.35.4':
2437 + dependencies:
2438 + '@emnapi/runtime': 1.11.3
2439 + optional: true
2440 +
2441 + '@img/sharp-webcontainers-wasm32@0.35.4':
2442 + dependencies:
2443 + '@img/sharp-wasm32': 0.35.4
2444 + optional: true
2445 +
2446 + '@img/sharp-win32-arm64@0.35.4':
2447 + optional: true
2448 +
2449 + '@img/sharp-win32-ia32@0.35.4':
2450 + optional: true
2451 +
2452 + '@img/sharp-win32-x64@0.35.4':
2453 + optional: true
2454 +
2455 + '@jridgewell/gen-mapping@0.3.13':
2456 + dependencies:
2457 + '@jridgewell/sourcemap-codec': 1.6.0
2458 + '@jridgewell/trace-mapping': 0.3.31
2459 +
2460 + '@jridgewell/remapping@2.3.5':
2461 + dependencies:
2462 + '@jridgewell/gen-mapping': 0.3.13
2463 + '@jridgewell/trace-mapping': 0.3.31
2464 +
2465 + '@jridgewell/resolve-uri@3.1.2': {}
2466 +
2467 + '@jridgewell/sourcemap-codec@1.6.0': {}
2468 +
2469 + '@jridgewell/trace-mapping@0.3.31':
2470 + dependencies:
2471 + '@jridgewell/resolve-uri': 3.1.2
2472 + '@jridgewell/sourcemap-codec': 1.6.0
2473 +
2474 + '@mapbox/jsonlint-lines-primitives@2.0.3': {}
2475 +
2476 + '@mapbox/point-geometry@1.1.0': {}
2477 +
2478 + '@mapbox/tiny-sdf@2.2.0': {}
2479 +
2480 + '@mapbox/unitbezier@1.0.0': {}
2481 +
2482 + '@mapbox/vector-tile@3.0.0':
2483 + dependencies:
2484 + '@mapbox/point-geometry': 1.1.0
2485 + '@types/geojson': 7946.0.16
2486 + pbf: 5.1.2
2487 +
2488 + '@maplibre/geojson-vt@6.1.1':
2489 + dependencies:
2490 + kdbush: 4.1.0
2491 +
2492 + '@maplibre/maplibre-gl-style-spec@26.4.2':
2493 + dependencies:
2494 + '@mapbox/jsonlint-lines-primitives': 2.0.3
2495 + '@mapbox/unitbezier': 1.0.0
2496 + json-stringify-pretty-compact: 4.0.0
2497 + minimist: 1.2.8
2498 + quickselect: 3.0.0
2499 + tinyqueue: 3.0.0
2500 +
2501 + '@maplibre/mlt@1.2.1':
2502 + dependencies:
2503 + '@mapbox/point-geometry': 1.1.0
2504 +
2505 + '@maplibre/vt-pbf@4.3.2':
2506 + dependencies:
2507 + '@mapbox/point-geometry': 1.1.0
2508 + '@types/geojson': 7946.0.16
2509 + pbf: 5.1.2
2510 +
2511 + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
2512 + dependencies:
2513 + '@emnapi/core': 1.10.0
2514 + '@emnapi/runtime': 1.10.0
2515 + '@tybys/wasm-util': 0.10.3
2516 + optional: true
2517 +
2518 + '@next/env@16.3.4': {}
2519 +
2520 + '@next/eslint-plugin-next@16.3.4(eslint@9.39.5(jiti@2.7.0))':
2521 + dependencies:
2522 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.7.0))
2523 + fast-glob: 3.3.1
2524 + transitivePeerDependencies:
2525 + - eslint
2526 +
2527 + '@next/swc-darwin-arm64@16.3.4':
2528 + optional: true
2529 +
2530 + '@next/swc-darwin-x64@16.3.4':
2531 + optional: true
2532 +
2533 + '@next/swc-linux-arm64-gnu@16.3.4':
2534 + optional: true
2535 +
2536 + '@next/swc-linux-arm64-musl@16.3.4':
2537 + optional: true
2538 +
2539 + '@next/swc-linux-x64-gnu@16.3.4':
2540 + optional: true
2541 +
2542 + '@next/swc-linux-x64-musl@16.3.4':
2543 + optional: true
2544 +
2545 + '@next/swc-win32-arm64-msvc@16.3.4':
2546 + optional: true
2547 +
2548 + '@next/swc-win32-x64-msvc@16.3.4':
2549 + optional: true
2550 +
2551 + '@nodelib/fs.scandir@2.1.5':
2552 + dependencies:
2553 + '@nodelib/fs.stat': 2.0.5
2554 + run-parallel: 1.2.0
2555 +
2556 + '@nodelib/fs.stat@2.0.5': {}
2557 +
2558 + '@nodelib/fs.walk@1.2.8':
2559 + dependencies:
2560 + '@nodelib/fs.scandir': 2.1.5
2561 + fastq: 1.20.3
2562 +
2563 + '@nolyfill/is-core-module@1.0.39': {}
2564 +
2565 + '@rtsao/scc@1.1.0': {}
2566 +
2567 + '@swc/helpers@0.5.23':
2568 + dependencies:
2569 + tslib: 2.8.1
2570 +
2571 + '@tailwindcss/node@4.3.3':
2572 + dependencies:
2573 + '@jridgewell/remapping': 2.3.5
2574 + enhanced-resolve: 5.24.5
2575 + jiti: 2.7.0
2576 + lightningcss: 1.32.0
2577 + magic-string: 0.30.21
2578 + source-map-js: 1.2.1
2579 + tailwindcss: 4.3.3
2580 +
2581 + '@tailwindcss/oxide-android-arm64@4.3.3':
2582 + optional: true
2583 +
2584 + '@tailwindcss/oxide-darwin-arm64@4.3.3':
2585 + optional: true
2586 +
2587 + '@tailwindcss/oxide-darwin-x64@4.3.3':
2588 + optional: true
2589 +
2590 + '@tailwindcss/oxide-freebsd-x64@4.3.3':
2591 + optional: true
2592 +
2593 + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
2594 + optional: true
2595 +
2596 + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
2597 + optional: true
2598 +
2599 + '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
2600 + optional: true
2601 +
2602 + '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
2603 + optional: true
2604 +
2605 + '@tailwindcss/oxide-linux-x64-musl@4.3.3':
2606 + optional: true
2607 +
2608 + '@tailwindcss/oxide-wasm32-wasi@4.3.3':
2609 + optional: true
2610 +
2611 + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
2612 + optional: true
2613 +
2614 + '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
2615 + optional: true
2616 +
2617 + '@tailwindcss/oxide@4.3.3':
2618 + optionalDependencies:
2619 + '@tailwindcss/oxide-android-arm64': 4.3.3
2620 + '@tailwindcss/oxide-darwin-arm64': 4.3.3
2621 + '@tailwindcss/oxide-darwin-x64': 4.3.3
2622 + '@tailwindcss/oxide-freebsd-x64': 4.3.3
2623 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
2624 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
2625 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
2626 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
2627 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3
2628 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3
2629 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
2630 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
2631 +
2632 + '@tailwindcss/postcss@4.3.3':
2633 + dependencies:
2634 + '@alloc/quick-lru': 5.3.0
2635 + '@tailwindcss/node': 4.3.3
2636 + '@tailwindcss/oxide': 4.3.3
2637 + postcss: 8.5.28
2638 + tailwindcss: 4.3.3
2639 +
2640 + '@tybys/wasm-util@0.10.3':
2641 + dependencies:
2642 + tslib: 2.8.1
2643 + optional: true
2644 +
2645 + '@types/estree@1.0.9': {}
2646 +
2647 + '@types/geojson@7946.0.16': {}
2648 +
2649 + '@types/json-schema@7.0.15': {}
2650 +
2651 + '@types/json5@0.0.29': {}
2652 +
2653 + '@types/node@24.13.4':
2654 + dependencies:
2655 + undici-types: 7.18.2
2656 +
2657 + '@types/react-dom@19.3.0(@types/react@19.3.0)':
2658 + dependencies:
2659 + '@types/react': 19.3.0
2660 +
2661 + '@types/react@19.3.0':
2662 + dependencies:
2663 + csstype: 3.2.3
2664 +
2665 + '@types/topojson-client@3.1.5':
2666 + dependencies:
2667 + '@types/geojson': 7946.0.16
2668 + '@types/topojson-specification': 1.0.5
2669 +
2670 + '@types/topojson-specification@1.0.5':
2671 + dependencies:
2672 + '@types/geojson': 7946.0.16
2673 +
2674 + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2675 + dependencies:
2676 + '@eslint-community/regexpp': 4.12.2
2677 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2678 + '@typescript-eslint/scope-manager': 8.70.0
2679 + '@typescript-eslint/type-utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2680 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2681 + '@typescript-eslint/visitor-keys': 8.70.0
2682 + eslint: 9.39.5(jiti@2.7.0)
2683 + ignore: 7.0.9
2684 + natural-compare: 1.4.0
2685 + ts-api-utils: 2.5.0(typescript@5.9.3)
2686 + typescript: 5.9.3
2687 + transitivePeerDependencies:
2688 + - supports-color
2689 +
2690 + '@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2691 + dependencies:
2692 + '@typescript-eslint/scope-manager': 8.70.0
2693 + '@typescript-eslint/types': 8.70.0
2694 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2695 + '@typescript-eslint/visitor-keys': 8.70.0
2696 + debug: 4.4.3
2697 + eslint: 9.39.5(jiti@2.7.0)
2698 + typescript: 5.9.3
2699 + transitivePeerDependencies:
2700 + - supports-color
2701 +
2702 + '@typescript-eslint/project-service@8.70.0(typescript@5.9.3)':
2703 + dependencies:
2704 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
2705 + '@typescript-eslint/types': 8.70.0
2706 + debug: 4.4.3
2707 + typescript: 5.9.3
2708 + transitivePeerDependencies:
2709 + - supports-color
2710 +
2711 + '@typescript-eslint/scope-manager@8.70.0':
2712 + dependencies:
2713 + '@typescript-eslint/types': 8.70.0
2714 + '@typescript-eslint/visitor-keys': 8.70.0
2715 +
2716 + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.9.3)':
2717 + dependencies:
2718 + typescript: 5.9.3
2719 +
2720 + '@typescript-eslint/type-utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2721 + dependencies:
2722 + '@typescript-eslint/types': 8.70.0
2723 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2724 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
2725 + debug: 4.4.3
2726 + eslint: 9.39.5(jiti@2.7.0)
2727 + ts-api-utils: 2.5.0(typescript@5.9.3)
2728 + typescript: 5.9.3
2729 + transitivePeerDependencies:
2730 + - supports-color
2731 +
2732 + '@typescript-eslint/types@8.70.0': {}
2733 +
2734 + '@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)':
2735 + dependencies:
2736 + '@typescript-eslint/project-service': 8.70.0(typescript@5.9.3)
2737 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@5.9.3)
2738 + '@typescript-eslint/types': 8.70.0
2739 + '@typescript-eslint/visitor-keys': 8.70.0
2740 + debug: 4.4.3
2741 + minimatch: 10.2.6
2742 + semver: 7.8.5
2743 + tinyglobby: 0.2.17
2744 + ts-api-utils: 2.5.0(typescript@5.9.3)
2745 + typescript: 5.9.3
2746 + transitivePeerDependencies:
2747 + - supports-color
2748 +
2749 + '@typescript-eslint/utils@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)':
2750 + dependencies:
2751 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
2752 + '@typescript-eslint/scope-manager': 8.70.0
2753 + '@typescript-eslint/types': 8.70.0
2754 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
2755 + eslint: 9.39.5(jiti@2.7.0)
2756 + typescript: 5.9.3
2757 + transitivePeerDependencies:
2758 + - supports-color
2759 +
2760 + '@typescript-eslint/visitor-keys@8.70.0':
2761 + dependencies:
2762 + '@typescript-eslint/types': 8.70.0
2763 + eslint-visitor-keys: 5.0.1
2764 +
2765 + '@unrs/resolver-binding-android-arm-eabi@1.12.2':
2766 + optional: true
2767 +
2768 + '@unrs/resolver-binding-android-arm64@1.12.2':
2769 + optional: true
2770 +
2771 + '@unrs/resolver-binding-darwin-arm64@1.12.2':
2772 + optional: true
2773 +
2774 + '@unrs/resolver-binding-darwin-x64@1.12.2':
2775 + optional: true
2776 +
2777 + '@unrs/resolver-binding-freebsd-x64@1.12.2':
2778 + optional: true
2779 +
2780 + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2':
2781 + optional: true
2782 +
2783 + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2':
2784 + optional: true
2785 +
2786 + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2':
2787 + optional: true
2788 +
2789 + '@unrs/resolver-binding-linux-arm64-musl@1.12.2':
2790 + optional: true
2791 +
2792 + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
2793 + optional: true
2794 +
2795 + '@unrs/resolver-binding-linux-loong64-musl@1.12.2':
2796 + optional: true
2797 +
2798 + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
2799 + optional: true
2800 +
2801 + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
2802 + optional: true
2803 +
2804 + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
2805 + optional: true
2806 +
2807 + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
2808 + optional: true
2809 +
2810 + '@unrs/resolver-binding-linux-x64-gnu@1.12.2':
2811 + optional: true
2812 +
2813 + '@unrs/resolver-binding-linux-x64-musl@1.12.2':
2814 + optional: true
2815 +
2816 + '@unrs/resolver-binding-openharmony-arm64@1.12.2':
2817 + optional: true
2818 +
2819 + '@unrs/resolver-binding-wasm32-wasi@1.12.2':
2820 + dependencies:
2821 + '@emnapi/core': 1.10.0
2822 + '@emnapi/runtime': 1.10.0
2823 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
2824 + optional: true
2825 +
2826 + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2':
2827 + optional: true
2828 +
2829 + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2':
2830 + optional: true
2831 +
2832 + '@unrs/resolver-binding-win32-x64-msvc@1.12.2':
2833 + optional: true
2834 +
2835 + acorn-jsx@5.3.2(acorn@8.18.0):
2836 + dependencies:
2837 + acorn: 8.18.0
2838 +
2839 + acorn@8.18.0: {}
2840 +
2841 + ajv@6.15.0:
2842 + dependencies:
2843 + fast-deep-equal: 3.1.3
2844 + fast-json-stable-stringify: 2.1.0
2845 + json-schema-traverse: 0.4.1
2846 + uri-js: 4.4.1
2847 +
2848 + ansi-styles@4.3.0:
2849 + dependencies:
2850 + color-convert: 2.0.1
2851 +
2852 + argparse@2.0.1: {}
2853 +
2854 + aria-query@5.3.2: {}
2855 +
2856 + array-buffer-byte-length@1.0.2:
2857 + dependencies:
2858 + call-bound: 1.0.4
2859 + is-array-buffer: 3.0.5
2860 +
2861 + array-includes@3.2.0:
2862 + dependencies:
2863 + call-bind: 1.0.9
2864 + call-bound: 1.0.4
2865 + define-properties: 1.2.1
2866 + es-abstract: 1.24.2
2867 + es-object-atoms: 1.1.2
2868 + es-shim-unscopables: 1.1.0
2869 + is-string: 1.1.1
2870 + math-intrinsics: 1.1.0
2871 +
2872 + array.prototype.findlast@1.2.5:
2873 + dependencies:
2874 + call-bind: 1.0.9
2875 + define-properties: 1.2.1
2876 + es-abstract: 1.24.2
2877 + es-errors: 1.3.0
2878 + es-object-atoms: 1.1.2
2879 + es-shim-unscopables: 1.1.0
2880 +
2881 + array.prototype.findlastindex@1.2.6:
2882 + dependencies:
2883 + call-bind: 1.0.9
2884 + call-bound: 1.0.4
2885 + define-properties: 1.2.1
2886 + es-abstract: 1.24.2
2887 + es-errors: 1.3.0
2888 + es-object-atoms: 1.1.2
2889 + es-shim-unscopables: 1.1.0
2890 +
2891 + array.prototype.flat@1.3.3:
2892 + dependencies:
2893 + call-bind: 1.0.9
2894 + define-properties: 1.2.1
2895 + es-abstract: 1.24.2
2896 + es-shim-unscopables: 1.1.0
2897 +
2898 + array.prototype.flatmap@1.3.3:
2899 + dependencies:
2900 + call-bind: 1.0.9
2901 + define-properties: 1.2.1
2902 + es-abstract: 1.24.2
2903 + es-shim-unscopables: 1.1.0
2904 +
2905 + array.prototype.tosorted@1.1.4:
2906 + dependencies:
2907 + call-bind: 1.0.9
2908 + define-properties: 1.2.1
2909 + es-abstract: 1.24.2
2910 + es-errors: 1.3.0
2911 + es-shim-unscopables: 1.1.0
2912 +
2913 + arraybuffer.prototype.slice@1.0.4:
2914 + dependencies:
2915 + array-buffer-byte-length: 1.0.2
2916 + call-bind: 1.0.9
2917 + define-properties: 1.2.1
2918 + es-abstract: 1.24.2
2919 + es-errors: 1.3.0
2920 + get-intrinsic: 1.3.0
2921 + is-array-buffer: 3.0.5
2922 +
2923 + ast-types-flow@0.0.8: {}
2924 +
2925 + async-function@1.0.0: {}
2926 +
2927 + available-typed-arrays@1.0.7:
2928 + dependencies:
2929 + possible-typed-array-names: 1.1.0
2930 +
2931 + axe-core@4.13.0: {}
2932 +
2933 + axobject-query@4.1.0: {}
2934 +
2935 + balanced-match@1.0.2: {}
2936 +
2937 + balanced-match@4.0.4: {}
2938 +
2939 + baseline-browser-mapping@2.11.22: {}
2940 +
2941 + bidi-js@1.1.0:
2942 + dependencies:
2943 + require-from-string: 2.0.2
2944 +
2945 + brace-expansion@1.1.18:
2946 + dependencies:
2947 + balanced-match: 1.0.2
2948 + concat-map: 0.0.1
2949 +
2950 + brace-expansion@5.0.9:
2951 + dependencies:
2952 + balanced-match: 4.0.4
2953 +
2954 + braces@3.0.3:
2955 + dependencies:
2956 + fill-range: 7.1.1
2957 +
2958 + browserslist@4.28.9:
2959 + dependencies:
2960 + baseline-browser-mapping: 2.11.22
2961 + caniuse-lite: 1.0.30001810
2962 + electron-to-chromium: 1.5.427
2963 + node-releases: 2.0.55
2964 + update-browserslist-db: 1.3.3(browserslist@4.28.9)
2965 +
2966 + call-bind-apply-helpers@1.0.2:
2967 + dependencies:
2968 + es-errors: 1.3.0
2969 + function-bind: 1.1.2
2970 +
2971 + call-bind@1.0.9:
2972 + dependencies:
2973 + call-bind-apply-helpers: 1.0.2
2974 + es-define-property: 1.0.1
2975 + get-intrinsic: 1.3.0
2976 + set-function-length: 1.2.2
2977 +
2978 + call-bound@1.0.4:
2979 + dependencies:
2980 + call-bind-apply-helpers: 1.0.2
2981 + get-intrinsic: 1.3.0
2982 +
2983 + callsites@3.1.0: {}
2984 +
2985 + caniuse-lite@1.0.30001810: {}
2986 +
2987 + chalk@4.1.2:
2988 + dependencies:
2989 + ansi-styles: 4.3.0
2990 + supports-color: 7.2.0
2991 +
2992 + client-only@0.0.1: {}
2993 +
2994 + color-convert@2.0.1:
2995 + dependencies:
2996 + color-name: 1.1.4
2997 +
2998 + color-name@1.1.4: {}
2999 +
3000 + commander@2.20.3: {}
3001 +
3002 + concat-map@0.0.1: {}
3003 +
3004 + convert-source-map@2.0.0: {}
3005 +
3006 + cross-spawn@7.0.6:
3007 + dependencies:
3008 + path-key: 3.1.1
3009 + shebang-command: 2.0.0
3010 + which: 2.0.2
3011 +
3012 + csstype@3.2.3: {}
3013 +
3014 + damerau-levenshtein@1.0.8: {}
3015 +
3016 + data-view-buffer@1.0.2:
3017 + dependencies:
3018 + call-bound: 1.0.4
3019 + es-errors: 1.3.0
3020 + is-data-view: 1.0.2
3021 +
3022 + data-view-byte-length@1.0.2:
3023 + dependencies:
3024 + call-bound: 1.0.4
3025 + es-errors: 1.3.0
3026 + is-data-view: 1.0.2
3027 +
3028 + data-view-byte-offset@1.0.1:
3029 + dependencies:
3030 + call-bound: 1.0.4
3031 + es-errors: 1.3.0
3032 + is-data-view: 1.0.2
3033 +
3034 + debug@3.2.7:
3035 + dependencies:
3036 + ms: 2.1.3
3037 +
3038 + debug@4.4.3:
3039 + dependencies:
3040 + ms: 2.1.3
3041 +
3042 + deep-is@0.1.4: {}
3043 +
3044 + define-data-property@1.1.4:
3045 + dependencies:
3046 + es-define-property: 1.0.1
3047 + es-errors: 1.3.0
3048 + gopd: 1.2.0
3049 +
3050 + define-properties@1.2.1:
3051 + dependencies:
3052 + define-data-property: 1.1.4
3053 + has-property-descriptors: 1.0.2
3054 + object-keys: 1.1.1
3055 +
3056 + detect-libc@2.1.2: {}
3057 +
3058 + doctrine@2.1.0:
3059 + dependencies:
3060 + esutils: 2.0.3
3061 +
3062 + dunder-proto@1.0.1:
3063 + dependencies:
3064 + call-bind-apply-helpers: 1.0.2
3065 + es-errors: 1.3.0
3066 + gopd: 1.2.0
3067 +
3068 + earcut@3.2.3: {}
3069 +
3070 + echarts@6.1.0:
3071 + dependencies:
3072 + tslib: 2.3.0
3073 + zrender: 6.1.0
3074 +
3075 + electron-to-chromium@1.5.427: {}
3076 +
3077 + emoji-regex@9.2.2: {}
3078 +
3079 + enhanced-resolve@5.24.5:
3080 + dependencies:
3081 + graceful-fs: 4.2.11
3082 + tapable: 2.3.3
3083 +
3084 + es-abstract-get@1.0.0:
3085 + dependencies:
3086 + es-errors: 1.3.0
3087 + es-object-atoms: 1.1.2
3088 + is-callable: 1.2.7
3089 + object-inspect: 1.13.4
3090 +
3091 + es-abstract@1.24.2:
3092 + dependencies:
3093 + array-buffer-byte-length: 1.0.2
3094 + arraybuffer.prototype.slice: 1.0.4
3095 + available-typed-arrays: 1.0.7
3096 + call-bind: 1.0.9
3097 + call-bound: 1.0.4
3098 + data-view-buffer: 1.0.2
3099 + data-view-byte-length: 1.0.2
3100 + data-view-byte-offset: 1.0.1
3101 + es-define-property: 1.0.1
3102 + es-errors: 1.3.0
3103 + es-object-atoms: 1.1.2
3104 + es-set-tostringtag: 2.1.0
3105 + es-to-primitive: 1.3.4
3106 + function.prototype.name: 1.2.0
3107 + get-intrinsic: 1.3.0
3108 + get-proto: 1.0.1
3109 + get-symbol-description: 1.1.0
3110 + globalthis: 1.0.4
3111 + gopd: 1.2.0
3112 + has-property-descriptors: 1.0.2
3113 + has-proto: 1.2.0
3114 + has-symbols: 1.1.0
3115 + hasown: 2.0.4
3116 + internal-slot: 1.1.0
3117 + is-array-buffer: 3.0.5
3118 + is-callable: 1.2.7
3119 + is-data-view: 1.0.2
3120 + is-negative-zero: 2.0.3
3121 + is-regex: 1.2.1
3122 + is-set: 2.0.3
3123 + is-shared-array-buffer: 1.0.4
3124 + is-string: 1.1.1
3125 + is-typed-array: 1.1.15
3126 + is-weakref: 1.1.1
3127 + math-intrinsics: 1.1.0
3128 + object-inspect: 1.13.4
3129 + object-keys: 1.1.1
3130 + object.assign: 4.1.7
3131 + own-keys: 1.0.2
3132 + regexp.prototype.flags: 1.5.4
3133 + safe-array-concat: 1.1.4
3134 + safe-push-apply: 1.0.0
3135 + safe-regex-test: 1.1.0
3136 + set-proto: 1.0.0
3137 + stop-iteration-iterator: 1.1.0
3138 + string.prototype.trim: 1.2.11
3139 + string.prototype.trimend: 1.0.10
3140 + string.prototype.trimstart: 1.0.8
3141 + typed-array-buffer: 1.0.3
3142 + typed-array-byte-length: 1.0.3
3143 + typed-array-byte-offset: 1.0.4
3144 + typed-array-length: 1.0.8
3145 + unbox-primitive: 1.1.0
3146 + which-typed-array: 1.1.22
3147 +
3148 + es-define-property@1.0.1: {}
3149 +
3150 + es-errors@1.3.0: {}
3151 +
3152 + es-iterator-helpers@1.4.0:
3153 + dependencies:
3154 + call-bind: 1.0.9
3155 + call-bound: 1.0.4
3156 + define-properties: 1.2.1
3157 + es-abstract: 1.24.2
3158 + es-errors: 1.3.0
3159 + es-set-tostringtag: 2.1.0
3160 + function-bind: 1.1.2
3161 + get-intrinsic: 1.3.0
3162 + globalthis: 1.0.4
3163 + gopd: 1.2.0
3164 + has-property-descriptors: 1.0.2
3165 + has-proto: 1.2.0
3166 + has-symbols: 1.1.0
3167 + internal-slot: 1.1.0
3168 + iterator.prototype: 1.1.5
3169 + math-intrinsics: 1.1.0
3170 +
3171 + es-object-atoms@1.1.2:
3172 + dependencies:
3173 + es-errors: 1.3.0
3174 +
3175 + es-set-tostringtag@2.1.0:
3176 + dependencies:
3177 + es-errors: 1.3.0
3178 + get-intrinsic: 1.3.0
3179 + has-tostringtag: 1.0.2
3180 + hasown: 2.0.4
3181 +
3182 + es-shim-unscopables@1.1.0:
3183 + dependencies:
3184 + hasown: 2.0.4
3185 +
3186 + es-to-primitive@1.3.4:
3187 + dependencies:
3188 + es-abstract-get: 1.0.0
3189 + es-define-property: 1.0.1
3190 + es-errors: 1.3.0
3191 + is-callable: 1.2.7
3192 + is-date-object: 1.1.0
3193 + is-symbol: 1.1.1
3194 +
3195 + escalade@3.2.0: {}
3196 +
3197 + escape-string-regexp@4.0.0: {}
3198 +
3199 + eslint-config-next@16.3.4(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
3200 + dependencies:
3201 + '@next/eslint-plugin-next': 16.3.4(eslint@9.39.5(jiti@2.7.0))
3202 + eslint: 9.39.5(jiti@2.7.0)
3203 + eslint-import-resolver-node: 0.3.10
3204 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
3205 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3206 + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5(jiti@2.7.0))
3207 + eslint-plugin-react: 7.37.5(eslint@9.39.5(jiti@2.7.0))
3208 + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5(jiti@2.7.0))
3209 + globals: 16.4.0
3210 + typescript-eslint: 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3211 + optionalDependencies:
3212 + typescript: 5.9.3
3213 + transitivePeerDependencies:
3214 + - '@typescript-eslint/parser'
3215 + - eslint-import-resolver-webpack
3216 + - eslint-plugin-import-x
3217 + - supports-color
3218 +
3219 + eslint-import-resolver-node@0.3.10:
3220 + dependencies:
3221 + debug: 3.2.7
3222 + is-core-module: 2.16.2
3223 + resolve: 2.0.0-next.7
3224 + transitivePeerDependencies:
3225 + - supports-color
3226 +
3227 + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0)):
3228 + dependencies:
3229 + '@nolyfill/is-core-module': 1.0.39
3230 + debug: 4.4.3
3231 + eslint: 9.39.5(jiti@2.7.0)
3232 + get-tsconfig: 4.14.3
3233 + is-bun-module: 2.0.0
3234 + stable-hash: 0.0.5
3235 + tinyglobby: 0.2.17
3236 + unrs-resolver: 1.12.2
3237 + optionalDependencies:
3238 + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3239 + transitivePeerDependencies:
3240 + - supports-color
3241 +
3242 + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
3243 + dependencies:
3244 + debug: 3.2.7
3245 + optionalDependencies:
3246 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3247 + eslint: 9.39.5(jiti@2.7.0)
3248 + eslint-import-resolver-node: 0.3.10
3249 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.5(jiti@2.7.0))
3250 + transitivePeerDependencies:
3251 + - supports-color
3252 +
3253 + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0)):
3254 + dependencies:
3255 + '@rtsao/scc': 1.1.0
3256 + array-includes: 3.2.0
3257 + array.prototype.findlastindex: 1.2.6
3258 + array.prototype.flat: 1.3.3
3259 + array.prototype.flatmap: 1.3.3
3260 + debug: 3.2.7
3261 + doctrine: 2.1.0
3262 + eslint: 9.39.5(jiti@2.7.0)
3263 + eslint-import-resolver-node: 0.3.10
3264 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5(jiti@2.7.0))
3265 + hasown: 2.0.4
3266 + is-core-module: 2.16.2
3267 + is-glob: 4.0.3
3268 + minimatch: 3.1.5
3269 + object.fromentries: 2.0.8
3270 + object.groupby: 1.0.3
3271 + object.values: 1.2.1
3272 + semver: 6.3.1
3273 + string.prototype.trimend: 1.0.10
3274 + tsconfig-paths: 3.15.0
3275 + optionalDependencies:
3276 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
3277 + transitivePeerDependencies:
3278 + - eslint-import-resolver-typescript
3279 + - eslint-import-resolver-webpack
3280 + - supports-color
3281 +
3282 + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(jiti@2.7.0)):
3283 + dependencies:
3284 + aria-query: 5.3.2
3285 + array-includes: 3.2.0
3286 + array.prototype.flatmap: 1.3.3
3287 + ast-types-flow: 0.0.8
3288 + axe-core: 4.13.0
3289 + axobject-query: 4.1.0
3290 + damerau-levenshtein: 1.0.8
3291 + emoji-regex: 9.2.2
3292 + eslint: 9.39.5(jiti@2.7.0)
3293 + hasown: 2.0.4
3294 + jsx-ast-utils: 3.3.5
3295 + language-tags: 1.0.9
3296 + minimatch: 3.1.5
3297 + object.fromentries: 2.0.8
3298 + safe-regex-test: 1.1.0
3299 + string.prototype.includes: 2.0.1
3300 +
3301 + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)):
3302 + dependencies:
3303 + '@babel/core': 7.29.7
3304 + '@babel/parser': 7.29.8
3305 + eslint: 9.39.5(jiti@2.7.0)
3306 + hermes-parser: 0.25.1
3307 + zod: 4.6.2
3308 + zod-validation-error: 4.0.2(zod@4.6.2)
3309 + transitivePeerDependencies:
3310 + - supports-color
3311 +
3312 + eslint-plugin-react@7.37.5(eslint@9.39.5(jiti@2.7.0)):
3313 + dependencies:
3314 + array-includes: 3.2.0
3315 + array.prototype.findlast: 1.2.5
3316 + array.prototype.flatmap: 1.3.3
3317 + array.prototype.tosorted: 1.1.4
3318 + doctrine: 2.1.0
3319 + es-iterator-helpers: 1.4.0
3320 + eslint: 9.39.5(jiti@2.7.0)
3321 + estraverse: 5.3.0
3322 + hasown: 2.0.4
3323 + jsx-ast-utils: 3.3.5
3324 + minimatch: 3.1.5
3325 + object.entries: 1.1.9
3326 + object.fromentries: 2.0.8
3327 + object.values: 1.2.1
3328 + prop-types: 15.8.1
3329 + resolve: 2.0.0-next.7
3330 + semver: 6.3.1
3331 + string.prototype.matchall: 4.1.0
3332 + string.prototype.repeat: 1.0.0
3333 +
3334 + eslint-scope@8.4.0:
3335 + dependencies:
3336 + esrecurse: 4.3.0
3337 + estraverse: 5.3.0
3338 +
3339 + eslint-visitor-keys@3.4.3: {}
3340 +
3341 + eslint-visitor-keys@4.2.1: {}
3342 +
3343 + eslint-visitor-keys@5.0.1: {}
3344 +
3345 + eslint@9.39.5(jiti@2.7.0):
3346 + dependencies:
3347 + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0))
3348 + '@eslint-community/regexpp': 4.12.2
3349 + '@eslint/config-array': 0.21.2
3350 + '@eslint/config-helpers': 0.4.2
3351 + '@eslint/core': 0.17.0
3352 + '@eslint/eslintrc': 3.3.7
3353 + '@eslint/js': 9.39.5
3354 + '@eslint/plugin-kit': 0.4.1
3355 + '@humanfs/node': 0.16.8
3356 + '@humanwhocodes/module-importer': 1.0.1
3357 + '@humanwhocodes/retry': 0.4.3
3358 + '@types/estree': 1.0.9
3359 + ajv: 6.15.0
3360 + chalk: 4.1.2
3361 + cross-spawn: 7.0.6
3362 + debug: 4.4.3
3363 + escape-string-regexp: 4.0.0
3364 + eslint-scope: 8.4.0
3365 + eslint-visitor-keys: 4.2.1
3366 + espree: 10.4.0
3367 + esquery: 1.7.0
3368 + esutils: 2.0.3
3369 + fast-deep-equal: 3.1.3
3370 + file-entry-cache: 8.0.0
3371 + find-up: 5.0.0
3372 + glob-parent: 6.0.2
3373 + ignore: 5.3.2
3374 + imurmurhash: 0.1.4
3375 + is-glob: 4.0.3
3376 + json-stable-stringify-without-jsonify: 1.0.1
3377 + lodash.merge: 4.6.2
3378 + minimatch: 3.1.5
3379 + natural-compare: 1.4.0
3380 + optionator: 0.9.4
3381 + optionalDependencies:
3382 + jiti: 2.7.0
3383 + transitivePeerDependencies:
3384 + - supports-color
3385 +
3386 + espree@10.4.0:
3387 + dependencies:
3388 + acorn: 8.18.0
3389 + acorn-jsx: 5.3.2(acorn@8.18.0)
3390 + eslint-visitor-keys: 4.2.1
3391 +
3392 + esquery@1.7.0:
3393 + dependencies:
3394 + estraverse: 5.3.0
3395 +
3396 + esrecurse@4.3.0:
3397 + dependencies:
3398 + estraverse: 5.3.0
3399 +
3400 + estraverse@5.3.0: {}
3401 +
3402 + esutils@2.0.3: {}
3403 +
3404 + fast-deep-equal@3.1.3: {}
3405 +
3406 + fast-glob@3.3.1:
3407 + dependencies:
3408 + '@nodelib/fs.stat': 2.0.5
3409 + '@nodelib/fs.walk': 1.2.8
3410 + glob-parent: 5.1.2
3411 + merge2: 1.4.1
3412 + micromatch: 4.0.8
3413 +
3414 + fast-json-stable-stringify@2.1.0: {}
3415 +
3416 + fast-levenshtein@2.0.6: {}
3417 +
3418 + fastq@1.20.3:
3419 + dependencies:
3420 + reusify: 1.1.0
3421 +
3422 + fdir@6.5.0(picomatch@4.0.7):
3423 + optionalDependencies:
3424 + picomatch: 4.0.7
3425 +
3426 + file-entry-cache@8.0.0:
3427 + dependencies:
3428 + flat-cache: 4.0.1
3429 +
3430 + fill-range@7.1.1:
3431 + dependencies:
3432 + to-regex-range: 5.0.1
3433 +
3434 + find-up@5.0.0:
3435 + dependencies:
3436 + locate-path: 6.0.0
3437 + path-exists: 4.0.0
3438 +
3439 + flat-cache@4.0.1:
3440 + dependencies:
3441 + flatted: 3.4.4
3442 + keyv: 4.5.4
3443 +
3444 + flatted@3.4.4: {}
3445 +
3446 + for-each@0.3.5:
3447 + dependencies:
3448 + is-callable: 1.2.7
3449 +
3450 + function-bind@1.1.2: {}
3451 +
3452 + function.prototype.name@1.2.0:
3453 + dependencies:
3454 + call-bind: 1.0.9
3455 + call-bound: 1.0.4
3456 + es-define-property: 1.0.1
3457 + es-errors: 1.3.0
3458 + functions-have-names: 1.2.3
3459 + has-property-descriptors: 1.0.2
3460 + hasown: 2.0.4
3461 + is-callable: 1.2.7
3462 + is-document.all: 1.0.0
3463 +
3464 + functions-have-names@1.2.3: {}
3465 +
3466 + geist@1.7.2(next@16.3.4(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)):
3467 + dependencies:
3468 + next: 16.3.4(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
3469 +
3470 + generator-function@2.0.1: {}
3471 +
3472 + gensync@1.0.0-beta.2: {}
3473 +
3474 + get-intrinsic@1.3.0:
3475 + dependencies:
3476 + call-bind-apply-helpers: 1.0.2
3477 + es-define-property: 1.0.1
3478 + es-errors: 1.3.0
3479 + es-object-atoms: 1.1.2
3480 + function-bind: 1.1.2
3481 + get-proto: 1.0.1
3482 + gopd: 1.2.0
3483 + has-symbols: 1.1.0
3484 + hasown: 2.0.4
3485 + math-intrinsics: 1.1.0
3486 +
3487 + get-proto@1.0.1:
3488 + dependencies:
3489 + dunder-proto: 1.0.1
3490 + es-object-atoms: 1.1.2
3491 +
3492 + get-symbol-description@1.1.0:
3493 + dependencies:
3494 + call-bound: 1.0.4
3495 + es-errors: 1.3.0
3496 + get-intrinsic: 1.3.0
3497 +
3498 + get-tsconfig@4.14.3:
3499 + dependencies:
3500 + resolve-pkg-maps: 1.0.0
3501 +
3502 + gl-matrix@3.4.4: {}
3503 +
3504 + glob-parent@5.1.2:
3505 + dependencies:
3506 + is-glob: 4.0.3
3507 +
3508 + glob-parent@6.0.2:
3509 + dependencies:
3510 + is-glob: 4.0.3
3511 +
3512 + globals@14.0.0: {}
3513 +
3514 + globals@16.4.0: {}
3515 +
3516 + globalthis@1.0.4:
3517 + dependencies:
3518 + define-properties: 1.2.1
3519 + gopd: 1.2.0
3520 +
3521 + gopd@1.2.0: {}
3522 +
3523 + graceful-fs@4.2.11: {}
3524 +
3525 + has-bigints@1.1.0: {}
3526 +
3527 + has-flag@4.0.0: {}
3528 +
3529 + has-property-descriptors@1.0.2:
3530 + dependencies:
3531 + es-define-property: 1.0.1
3532 +
3533 + has-proto@1.2.0:
3534 + dependencies:
3535 + dunder-proto: 1.0.1
3536 +
3537 + has-symbols@1.1.0: {}
3538 +
3539 + has-tostringtag@1.0.2:
3540 + dependencies:
3541 + has-symbols: 1.1.0
3542 +
3543 + hasown@2.0.4:
3544 + dependencies:
3545 + function-bind: 1.1.2
3546 +
3547 + hermes-estree@0.25.1: {}
3548 +
3549 + hermes-parser@0.25.1:
3550 + dependencies:
3551 + hermes-estree: 0.25.1
3552 +
3553 + ignore@5.3.2: {}
3554 +
3555 + ignore@7.0.9: {}
3556 +
3557 + import-fresh@3.3.1:
3558 + dependencies:
3559 + parent-module: 1.0.1
3560 + resolve-from: 4.0.0
3561 +
3562 + imurmurhash@0.1.4: {}
3563 +
3564 + internal-slot@1.1.0:
3565 + dependencies:
3566 + es-errors: 1.3.0
3567 + hasown: 2.0.4
3568 + side-channel: 1.1.1
3569 +
3570 + is-array-buffer@3.0.5:
3571 + dependencies:
3572 + call-bind: 1.0.9
3573 + call-bound: 1.0.4
3574 + get-intrinsic: 1.3.0
3575 +
3576 + is-async-function@2.1.1:
3577 + dependencies:
3578 + async-function: 1.0.0
3579 + call-bound: 1.0.4
3580 + get-proto: 1.0.1
3581 + has-tostringtag: 1.0.2
3582 + safe-regex-test: 1.1.0
3583 +
3584 + is-bigint@1.1.0:
3585 + dependencies:
3586 + has-bigints: 1.1.0
3587 +
3588 + is-boolean-object@1.2.2:
3589 + dependencies:
3590 + call-bound: 1.0.4
3591 + has-tostringtag: 1.0.2
3592 +
3593 + is-bun-module@2.0.0:
3594 + dependencies:
3595 + semver: 7.8.5
3596 +
3597 + is-callable@1.2.7: {}
3598 +
3599 + is-core-module@2.16.2:
3600 + dependencies:
3601 + hasown: 2.0.4
3602 +
3603 + is-data-view@1.0.2:
3604 + dependencies:
3605 + call-bound: 1.0.4
3606 + get-intrinsic: 1.3.0
3607 + is-typed-array: 1.1.15
3608 +
3609 + is-date-object@1.1.0:
3610 + dependencies:
3611 + call-bound: 1.0.4
3612 + has-tostringtag: 1.0.2
3613 +
3614 + is-document.all@1.0.0:
3615 + dependencies:
3616 + call-bound: 1.0.4
3617 +
3618 + is-extglob@2.1.1: {}
3619 +
3620 + is-finalizationregistry@1.1.1:
3621 + dependencies:
3622 + call-bound: 1.0.4
3623 +
3624 + is-generator-function@1.1.2:
3625 + dependencies:
3626 + call-bound: 1.0.4
3627 + generator-function: 2.0.1
3628 + get-proto: 1.0.1
3629 + has-tostringtag: 1.0.2
3630 + safe-regex-test: 1.1.0
3631 +
3632 + is-glob@4.0.3:
3633 + dependencies:
3634 + is-extglob: 2.1.1
3635 +
3636 + is-map@2.0.3: {}
3637 +
3638 + is-negative-zero@2.0.3: {}
3639 +
3640 + is-number-object@1.1.1:
3641 + dependencies:
3642 + call-bound: 1.0.4
3643 + has-tostringtag: 1.0.2
3644 +
3645 + is-number@7.0.0: {}
3646 +
3647 + is-regex@1.2.1:
3648 + dependencies:
3649 + call-bound: 1.0.4
3650 + gopd: 1.2.0
3651 + has-tostringtag: 1.0.2
3652 + hasown: 2.0.4
3653 +
3654 + is-set@2.0.3: {}
3655 +
3656 + is-shared-array-buffer@1.0.4:
3657 + dependencies:
3658 + call-bound: 1.0.4
3659 +
3660 + is-string@1.1.1:
3661 + dependencies:
3662 + call-bound: 1.0.4
3663 + has-tostringtag: 1.0.2
3664 +
3665 + is-symbol@1.1.1:
3666 + dependencies:
3667 + call-bound: 1.0.4
3668 + has-symbols: 1.1.0
3669 + safe-regex-test: 1.1.0
3670 +
3671 + is-typed-array@1.1.15:
3672 + dependencies:
3673 + which-typed-array: 1.1.22
3674 +
3675 + is-weakmap@2.0.2: {}
3676 +
3677 + is-weakref@1.1.1:
3678 + dependencies:
3679 + call-bound: 1.0.4
3680 +
3681 + is-weakset@2.0.4:
3682 + dependencies:
3683 + call-bound: 1.0.4
3684 + get-intrinsic: 1.3.0
3685 +
3686 + isarray@2.0.5: {}
3687 +
3688 + isexe@2.0.0: {}
3689 +
3690 + iterator.prototype@1.1.5:
3691 + dependencies:
3692 + define-data-property: 1.1.4
3693 + es-object-atoms: 1.1.2
3694 + get-intrinsic: 1.3.0
3695 + get-proto: 1.0.1
3696 + has-symbols: 1.1.0
3697 + set-function-name: 2.0.2
3698 +
3699 + jiti@2.7.0: {}
3700 +
3701 + js-tokens@4.0.0: {}
3702 +
3703 + js-yaml@4.3.2:
3704 + dependencies:
3705 + argparse: 2.0.1
3706 +
3707 + jsesc@3.1.0: {}
3708 +
3709 + json-buffer@3.0.1: {}
3710 +
3711 + json-schema-traverse@0.4.1: {}
3712 +
3713 + json-stable-stringify-without-jsonify@1.0.1: {}
3714 +
3715 + json-stringify-pretty-compact@4.0.0: {}
3716 +
3717 + json5@1.0.2:
3718 + dependencies:
3719 + minimist: 1.2.8
3720 +
3721 + json5@2.2.3: {}
3722 +
3723 + jsx-ast-utils@3.3.5:
3724 + dependencies:
3725 + array-includes: 3.2.0
3726 + array.prototype.flat: 1.3.3
3727 + object.assign: 4.1.7
3728 + object.values: 1.2.1
3729 +
3730 + kdbush@4.1.0: {}
3731 +
3732 + keyv@4.5.4:
3733 + dependencies:
3734 + json-buffer: 3.0.1
3735 +
3736 + language-subtag-registry@0.3.23: {}
3737 +
3738 + language-tags@1.0.9:
3739 + dependencies:
3740 + language-subtag-registry: 0.3.23
3741 +
3742 + levn@0.4.1:
3743 + dependencies:
3744 + prelude-ls: 1.2.1
3745 + type-check: 0.4.0
3746 +
3747 + lightningcss-android-arm64@1.32.0:
3748 + optional: true
3749 +
3750 + lightningcss-darwin-arm64@1.32.0:
3751 + optional: true
3752 +
3753 + lightningcss-darwin-x64@1.32.0:
3754 + optional: true
3755 +
3756 + lightningcss-freebsd-x64@1.32.0:
3757 + optional: true
3758 +
3759 + lightningcss-linux-arm-gnueabihf@1.32.0:
3760 + optional: true
3761 +
3762 + lightningcss-linux-arm64-gnu@1.32.0:
3763 + optional: true
3764 +
3765 + lightningcss-linux-arm64-musl@1.32.0:
3766 + optional: true
3767 +
3768 + lightningcss-linux-x64-gnu@1.32.0:
3769 + optional: true
3770 +
3771 + lightningcss-linux-x64-musl@1.32.0:
3772 + optional: true
3773 +
3774 + lightningcss-win32-arm64-msvc@1.32.0:
3775 + optional: true
3776 +
3777 + lightningcss-win32-x64-msvc@1.32.0:
3778 + optional: true
3779 +
3780 + lightningcss@1.32.0:
3781 + dependencies:
3782 + detect-libc: 2.1.2
3783 + optionalDependencies:
3784 + lightningcss-android-arm64: 1.32.0
3785 + lightningcss-darwin-arm64: 1.32.0
3786 + lightningcss-darwin-x64: 1.32.0
3787 + lightningcss-freebsd-x64: 1.32.0
3788 + lightningcss-linux-arm-gnueabihf: 1.32.0
3789 + lightningcss-linux-arm64-gnu: 1.32.0
3790 + lightningcss-linux-arm64-musl: 1.32.0
3791 + lightningcss-linux-x64-gnu: 1.32.0
3792 + lightningcss-linux-x64-musl: 1.32.0
3793 + lightningcss-win32-arm64-msvc: 1.32.0
3794 + lightningcss-win32-x64-msvc: 1.32.0
3795 +
3796 + locate-path@6.0.0:
3797 + dependencies:
3798 + p-locate: 5.0.0
3799 +
3800 + lodash.merge@4.6.2: {}
3801 +
3802 + loose-envify@1.4.0:
3803 + dependencies:
3804 + js-tokens: 4.0.0
3805 +
3806 + lru-cache@5.1.1:
3807 + dependencies:
3808 + yallist: 3.1.1
3809 +
3810 + lucide-react@1.45.0(react@19.2.8):
3811 + dependencies:
3812 + react: 19.2.8
3813 +
3814 + magic-string@0.30.21:
3815 + dependencies:
3816 + '@jridgewell/sourcemap-codec': 1.6.0
3817 +
3818 + maplibre-gl@6.9.0:
3819 + dependencies:
3820 + '@mapbox/point-geometry': 1.1.0
3821 + '@mapbox/tiny-sdf': 2.2.0
3822 + '@mapbox/unitbezier': 1.0.0
3823 + '@mapbox/vector-tile': 3.0.0
3824 + '@maplibre/geojson-vt': 6.1.1
3825 + '@maplibre/maplibre-gl-style-spec': 26.4.2
3826 + '@maplibre/mlt': 1.2.1
3827 + '@maplibre/vt-pbf': 4.3.2
3828 + '@types/geojson': 7946.0.16
3829 + bidi-js: 1.1.0
3830 + earcut: 3.2.3
3831 + gl-matrix: 3.4.4
3832 + kdbush: 4.1.0
3833 + murmurhash-js: 1.0.0
3834 + pbf: 5.1.2
3835 + potpack: 2.1.0
3836 + quickselect: 3.0.0
3837 + tinyqueue: 3.0.0
3838 +
3839 + math-intrinsics@1.1.0: {}
3840 +
3841 + merge2@1.4.1: {}
3842 +
3843 + micromatch@4.0.8:
3844 + dependencies:
3845 + braces: 3.0.3
3846 + picomatch: 2.3.2
3847 +
3848 + minimatch@10.2.6:
3849 + dependencies:
3850 + brace-expansion: 5.0.9
3851 +
3852 + minimatch@3.1.5:
3853 + dependencies:
3854 + brace-expansion: 1.1.18
3855 +
3856 + minimist@1.2.8: {}
3857 +
3858 + ms@2.1.3: {}
3859 +
3860 + murmurhash-js@1.0.0: {}
3861 +
3862 + nanoid@3.3.19: {}
3863 +
3864 + napi-postinstall@0.3.4: {}
3865 +
3866 + natural-compare@1.4.0: {}
3867 +
3868 + next@16.3.4(@babel/core@7.29.7)(@types/node@24.13.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
3869 + dependencies:
3870 + '@next/env': 16.3.4
3871 + '@swc/helpers': 0.5.23
3872 + baseline-browser-mapping: 2.11.22
3873 + caniuse-lite: 1.0.30001810
3874 + postcss: 8.5.23
3875 + react: 19.2.8
3876 + react-dom: 19.2.8(react@19.2.8)
3877 + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8)
3878 + optionalDependencies:
3879 + '@next/swc-darwin-arm64': 16.3.4
3880 + '@next/swc-darwin-x64': 16.3.4
3881 + '@next/swc-linux-arm64-gnu': 16.3.4
3882 + '@next/swc-linux-arm64-musl': 16.3.4
3883 + '@next/swc-linux-x64-gnu': 16.3.4
3884 + '@next/swc-linux-x64-musl': 16.3.4
3885 + '@next/swc-win32-arm64-msvc': 16.3.4
3886 + '@next/swc-win32-x64-msvc': 16.3.4
3887 + sharp: 0.35.4(@types/node@24.13.4)
3888 + transitivePeerDependencies:
3889 + - '@babel/core'
3890 + - '@types/node'
3891 + - babel-plugin-macros
3892 +
3893 + node-exports-info@1.6.2:
3894 + dependencies:
3895 + array.prototype.flatmap: 1.3.3
3896 + es-errors: 1.3.0
3897 + object.entries: 1.1.9
3898 + semver: 6.3.1
3899 +
3900 + node-releases@2.0.55: {}
3901 +
3902 + object-assign@4.1.1: {}
3903 +
3904 + object-inspect@1.13.4: {}
3905 +
3906 + object-keys@1.1.1: {}
3907 +
3908 + object.assign@4.1.7:
3909 + dependencies:
3910 + call-bind: 1.0.9
3911 + call-bound: 1.0.4
3912 + define-properties: 1.2.1
3913 + es-object-atoms: 1.1.2
3914 + has-symbols: 1.1.0
3915 + object-keys: 1.1.1
3916 +
3917 + object.entries@1.1.9:
3918 + dependencies:
3919 + call-bind: 1.0.9
3920 + call-bound: 1.0.4
3921 + define-properties: 1.2.1
3922 + es-object-atoms: 1.1.2
3923 +
3924 + object.fromentries@2.0.8:
3925 + dependencies:
3926 + call-bind: 1.0.9
3927 + define-properties: 1.2.1
3928 + es-abstract: 1.24.2
3929 + es-object-atoms: 1.1.2
3930 +
3931 + object.groupby@1.0.3:
3932 + dependencies:
3933 + call-bind: 1.0.9
3934 + define-properties: 1.2.1
3935 + es-abstract: 1.24.2
3936 +
3937 + object.values@1.2.1:
3938 + dependencies:
3939 + call-bind: 1.0.9
3940 + call-bound: 1.0.4
3941 + define-properties: 1.2.1
3942 + es-object-atoms: 1.1.2
3943 +
3944 + optionator@0.9.4:
3945 + dependencies:
3946 + deep-is: 0.1.4
3947 + fast-levenshtein: 2.0.6
3948 + levn: 0.4.1
3949 + prelude-ls: 1.2.1
3950 + type-check: 0.4.0
3951 + word-wrap: 1.2.5
3952 +
3953 + own-keys@1.0.2:
3954 + dependencies:
3955 + call-bound: 1.0.4
3956 + get-intrinsic: 1.3.0
3957 + object-keys: 1.1.1
3958 + safe-push-apply: 1.0.0
3959 +
3960 + p-limit@3.1.0:
3961 + dependencies:
3962 + yocto-queue: 0.1.0
3963 +
3964 + p-locate@5.0.0:
3965 + dependencies:
3966 + p-limit: 3.1.0
3967 +
3968 + parent-module@1.0.1:
3969 + dependencies:
3970 + callsites: 3.1.0
3971 +
3972 + path-exists@4.0.0: {}
3973 +
3974 + path-key@3.1.1: {}
3975 +
3976 + path-parse@1.0.7: {}
3977 +
3978 + pbf@5.1.2:
3979 + dependencies:
3980 + resolve-protobuf-schema: 2.1.0
3981 +
3982 + picocolors@1.1.1: {}
3983 +
3984 + picomatch@2.3.2: {}
3985 +
3986 + picomatch@4.0.7: {}
3987 +
3988 + possible-typed-array-names@1.1.0: {}
3989 +
3990 + postcss@8.5.23:
3991 + dependencies:
3992 + nanoid: 3.3.19
3993 + picocolors: 1.1.1
3994 + source-map-js: 1.2.1
3995 +
3996 + postcss@8.5.28:
3997 + dependencies:
3998 + nanoid: 3.3.19
3999 + picocolors: 1.1.1
4000 + source-map-js: 1.2.1
4001 +
4002 + potpack@2.1.0: {}
4003 +
4004 + prelude-ls@1.2.1: {}
4005 +
4006 + prop-types@15.8.1:
4007 + dependencies:
4008 + loose-envify: 1.4.0
4009 + object-assign: 4.1.1
4010 + react-is: 16.13.1
4011 +
4012 + protocol-buffers-schema@3.6.1: {}
4013 +
4014 + punycode@2.3.1: {}
4015 +
4016 + queue-microtask@1.2.3: {}
4017 +
4018 + quickselect@3.0.0: {}
4019 +
4020 + react-dom@19.2.8(react@19.2.8):
4021 + dependencies:
4022 + react: 19.2.8
4023 + scheduler: 0.27.0
4024 +
4025 + react-is@16.13.1: {}
4026 +
4027 + react@19.2.8: {}
4028 +
4029 + reflect.getprototypeof@1.0.10:
4030 + dependencies:
4031 + call-bind: 1.0.9
4032 + define-properties: 1.2.1
4033 + es-abstract: 1.24.2
4034 + es-errors: 1.3.0
4035 + es-object-atoms: 1.1.2
4036 + get-intrinsic: 1.3.0
4037 + get-proto: 1.0.1
4038 + which-builtin-type: 1.2.1
4039 +
4040 + regexp.prototype.flags@1.5.4:
4041 + dependencies:
4042 + call-bind: 1.0.9
4043 + define-properties: 1.2.1
4044 + es-errors: 1.3.0
4045 + get-proto: 1.0.1
4046 + gopd: 1.2.0
4047 + set-function-name: 2.0.2
4048 +
4049 + require-from-string@2.0.2: {}
4050 +
4051 + resolve-from@4.0.0: {}
4052 +
4053 + resolve-pkg-maps@1.0.0: {}
4054 +
4055 + resolve-protobuf-schema@2.1.0:
4056 + dependencies:
4057 + protocol-buffers-schema: 3.6.1
4058 +
4059 + resolve@2.0.0-next.7:
4060 + dependencies:
4061 + es-errors: 1.3.0
4062 + is-core-module: 2.16.2
4063 + node-exports-info: 1.6.2
4064 + object-keys: 1.1.1
4065 + path-parse: 1.0.7
4066 + supports-preserve-symlinks-flag: 1.0.0
4067 +
4068 + reusify@1.1.0: {}
4069 +
4070 + run-parallel@1.2.0:
4071 + dependencies:
4072 + queue-microtask: 1.2.3
4073 +
4074 + safe-array-concat@1.1.4:
4075 + dependencies:
4076 + call-bind: 1.0.9
4077 + call-bound: 1.0.4
4078 + get-intrinsic: 1.3.0
4079 + has-symbols: 1.1.0
4080 + isarray: 2.0.5
4081 +
4082 + safe-push-apply@1.0.0:
4083 + dependencies:
4084 + es-errors: 1.3.0
4085 + isarray: 2.0.5
4086 +
4087 + safe-regex-test@1.1.0:
4088 + dependencies:
4089 + call-bound: 1.0.4
4090 + es-errors: 1.3.0
4091 + is-regex: 1.2.1
4092 +
4093 + scheduler@0.27.0: {}
4094 +
4095 + semver@6.3.1: {}
4096 +
4097 + semver@7.8.5: {}
4098 +
4099 + server-only@0.0.1: {}
4100 +
4101 + set-function-length@1.2.2:
4102 + dependencies:
4103 + define-data-property: 1.1.4
4104 + es-errors: 1.3.0
4105 + function-bind: 1.1.2
4106 + get-intrinsic: 1.3.0
4107 + gopd: 1.2.0
4108 + has-property-descriptors: 1.0.2
4109 +
4110 + set-function-name@2.0.2:
4111 + dependencies:
4112 + define-data-property: 1.1.4
4113 + es-errors: 1.3.0
4114 + functions-have-names: 1.2.3
4115 + has-property-descriptors: 1.0.2
4116 +
4117 + set-proto@1.0.0:
4118 + dependencies:
4119 + dunder-proto: 1.0.1
4120 + es-errors: 1.3.0
4121 + es-object-atoms: 1.1.2
4122 +
4123 + sharp@0.35.4(@types/node@24.13.4):
4124 + dependencies:
4125 + '@img/colour': 1.1.0
4126 + detect-libc: 2.1.2
4127 + semver: 7.8.5
4128 + optionalDependencies:
4129 + '@img/sharp-darwin-arm64': 0.35.4
4130 + '@img/sharp-darwin-x64': 0.35.4
4131 + '@img/sharp-freebsd-wasm32': 0.35.4
4132 + '@img/sharp-libvips-darwin-arm64': 1.3.3
4133 + '@img/sharp-libvips-darwin-x64': 1.3.3
4134 + '@img/sharp-libvips-linux-arm': 1.3.3
4135 + '@img/sharp-libvips-linux-arm64': 1.3.3
4136 + '@img/sharp-libvips-linux-ppc64': 1.3.3
4137 + '@img/sharp-libvips-linux-riscv64': 1.3.3
4138 + '@img/sharp-libvips-linux-s390x': 1.3.3
4139 + '@img/sharp-libvips-linux-x64': 1.3.3
4140 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
4141 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3
4142 + '@img/sharp-linux-arm': 0.35.4
4143 + '@img/sharp-linux-arm64': 0.35.4
4144 + '@img/sharp-linux-ppc64': 0.35.4
4145 + '@img/sharp-linux-riscv64': 0.35.4
4146 + '@img/sharp-linux-s390x': 0.35.4
4147 + '@img/sharp-linux-x64': 0.35.4
4148 + '@img/sharp-linuxmusl-arm64': 0.35.4
4149 + '@img/sharp-linuxmusl-x64': 0.35.4
4150 + '@img/sharp-webcontainers-wasm32': 0.35.4
4151 + '@img/sharp-win32-arm64': 0.35.4
4152 + '@img/sharp-win32-ia32': 0.35.4
4153 + '@img/sharp-win32-x64': 0.35.4
4154 + '@types/node': 24.13.4
4155 + optional: true
4156 +
4157 + shebang-command@2.0.0:
4158 + dependencies:
4159 + shebang-regex: 3.0.0
4160 +
4161 + shebang-regex@3.0.0: {}
4162 +
4163 + side-channel-list@1.0.1:
4164 + dependencies:
4165 + es-errors: 1.3.0
4166 + object-inspect: 1.13.4
4167 +
4168 + side-channel-map@1.0.1:
4169 + dependencies:
4170 + call-bound: 1.0.4
4171 + es-errors: 1.3.0
4172 + get-intrinsic: 1.3.0
4173 + object-inspect: 1.13.4
4174 +
4175 + side-channel-weakmap@1.0.2:
4176 + dependencies:
4177 + call-bound: 1.0.4
4178 + es-errors: 1.3.0
4179 + get-intrinsic: 1.3.0
4180 + object-inspect: 1.13.4
4181 + side-channel-map: 1.0.1
4182 +
4183 + side-channel@1.1.1:
4184 + dependencies:
4185 + es-errors: 1.3.0
4186 + object-inspect: 1.13.4
4187 + side-channel-list: 1.0.1
4188 + side-channel-map: 1.0.1
4189 + side-channel-weakmap: 1.0.2
4190 +
4191 + source-map-js@1.2.1: {}
4192 +
4193 + stable-hash@0.0.5: {}
4194 +
4195 + stop-iteration-iterator@1.1.0:
4196 + dependencies:
4197 + es-errors: 1.3.0
4198 + internal-slot: 1.1.0
4199 +
4200 + string.prototype.includes@2.0.1:
4201 + dependencies:
4202 + call-bind: 1.0.9
4203 + define-properties: 1.2.1
4204 + es-abstract: 1.24.2
4205 +
4206 + string.prototype.matchall@4.1.0:
4207 + dependencies:
4208 + call-bind: 1.0.9
4209 + call-bound: 1.0.4
4210 + define-properties: 1.2.1
4211 + es-abstract: 1.24.2
4212 + es-errors: 1.3.0
4213 + es-object-atoms: 1.1.2
4214 + get-intrinsic: 1.3.0
4215 + gopd: 1.2.0
4216 + has-symbols: 1.1.0
4217 + internal-slot: 1.1.0
4218 + regexp.prototype.flags: 1.5.4
4219 + set-function-name: 2.0.2
4220 + side-channel: 1.1.1
4221 +
4222 + string.prototype.repeat@1.0.0:
4223 + dependencies:
4224 + define-properties: 1.2.1
4225 + es-abstract: 1.24.2
4226 +
4227 + string.prototype.trim@1.2.11:
4228 + dependencies:
4229 + call-bind: 1.0.9
4230 + call-bound: 1.0.4
4231 + define-data-property: 1.1.4
4232 + define-properties: 1.2.1
4233 + es-abstract: 1.24.2
4234 + es-object-atoms: 1.1.2
4235 + has-property-descriptors: 1.0.2
4236 + safe-regex-test: 1.1.0
4237 +
4238 + string.prototype.trimend@1.0.10:
4239 + dependencies:
4240 + call-bind: 1.0.9
4241 + call-bound: 1.0.4
4242 + define-properties: 1.2.1
4243 + es-object-atoms: 1.1.2
4244 +
4245 + string.prototype.trimstart@1.0.8:
4246 + dependencies:
4247 + call-bind: 1.0.9
4248 + define-properties: 1.2.1
4249 + es-object-atoms: 1.1.2
4250 +
4251 + strip-bom@3.0.0: {}
4252 +
4253 + strip-json-comments@3.1.1: {}
4254 +
4255 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8):
4256 + dependencies:
4257 + client-only: 0.0.1
4258 + react: 19.2.8
4259 + optionalDependencies:
4260 + '@babel/core': 7.29.7
4261 +
4262 + supports-color@7.2.0:
4263 + dependencies:
4264 + has-flag: 4.0.0
4265 +
4266 + supports-preserve-symlinks-flag@1.0.0: {}
4267 +
4268 + tailwindcss@4.3.3: {}
4269 +
4270 + tapable@2.3.3: {}
4271 +
4272 + tinyglobby@0.2.17:
4273 + dependencies:
4274 + fdir: 6.5.0(picomatch@4.0.7)
4275 + picomatch: 4.0.7
4276 +
4277 + tinyqueue@3.0.0: {}
4278 +
4279 + to-regex-range@5.0.1:
4280 + dependencies:
4281 + is-number: 7.0.0
4282 +
4283 + topojson-client@3.1.0:
4284 + dependencies:
4285 + commander: 2.20.3
4286 +
4287 + ts-api-utils@2.5.0(typescript@5.9.3):
4288 + dependencies:
4289 + typescript: 5.9.3
4290 +
4291 + tsconfig-paths@3.15.0:
4292 + dependencies:
4293 + '@types/json5': 0.0.29
4294 + json5: 1.0.2
4295 + minimist: 1.2.8
4296 + strip-bom: 3.0.0
4297 +
4298 + tslib@2.3.0: {}
4299 +
4300 + tslib@2.8.1: {}
4301 +
4302 + type-check@0.4.0:
4303 + dependencies:
4304 + prelude-ls: 1.2.1
4305 +
4306 + typed-array-buffer@1.0.3:
4307 + dependencies:
4308 + call-bound: 1.0.4
4309 + es-errors: 1.3.0
4310 + is-typed-array: 1.1.15
4311 +
4312 + typed-array-byte-length@1.0.3:
4313 + dependencies:
4314 + call-bind: 1.0.9
4315 + for-each: 0.3.5
4316 + gopd: 1.2.0
4317 + has-proto: 1.2.0
4318 + is-typed-array: 1.1.15
4319 +
4320 + typed-array-byte-offset@1.0.4:
4321 + dependencies:
4322 + available-typed-arrays: 1.0.7
4323 + call-bind: 1.0.9
4324 + for-each: 0.3.5
4325 + gopd: 1.2.0
4326 + has-proto: 1.2.0
4327 + is-typed-array: 1.1.15
4328 + reflect.getprototypeof: 1.0.10
4329 +
4330 + typed-array-length@1.0.8:
4331 + dependencies:
4332 + call-bind: 1.0.9
4333 + for-each: 0.3.5
4334 + gopd: 1.2.0
4335 + is-typed-array: 1.1.15
4336 + possible-typed-array-names: 1.1.0
4337 + reflect.getprototypeof: 1.0.10
4338 +
4339 + typescript-eslint@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3):
4340 + dependencies:
4341 + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4342 + '@typescript-eslint/parser': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4343 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@5.9.3)
4344 + '@typescript-eslint/utils': 8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
4345 + eslint: 9.39.5(jiti@2.7.0)
4346 + typescript: 5.9.3
4347 + transitivePeerDependencies:
4348 + - supports-color
4349 +
4350 + typescript@5.9.3: {}
4351 +
4352 + unbox-primitive@1.1.0:
4353 + dependencies:
4354 + call-bound: 1.0.4
4355 + has-bigints: 1.1.0
4356 + has-symbols: 1.1.0
4357 + which-boxed-primitive: 1.1.1
4358 +
4359 + undici-types@7.18.2: {}
4360 +
4361 + unrs-resolver@1.12.2:
4362 + dependencies:
4363 + napi-postinstall: 0.3.4
4364 + optionalDependencies:
4365 + '@unrs/resolver-binding-android-arm-eabi': 1.12.2
4366 + '@unrs/resolver-binding-android-arm64': 1.12.2
4367 + '@unrs/resolver-binding-darwin-arm64': 1.12.2
4368 + '@unrs/resolver-binding-darwin-x64': 1.12.2
4369 + '@unrs/resolver-binding-freebsd-x64': 1.12.2
4370 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2
4371 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2
4372 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2
4373 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2
4374 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2
4375 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2
4376 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2
4377 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2
4378 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2
4379 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2
4380 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2
4381 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2
4382 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2
4383 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2
4384 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2
4385 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2
4386 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2
4387 +
4388 + update-browserslist-db@1.3.3(browserslist@4.28.9):
4389 + dependencies:
4390 + browserslist: 4.28.9
4391 + escalade: 3.2.0
4392 + picocolors: 1.1.1
4393 +
4394 + uri-js@4.4.1:
4395 + dependencies:
4396 + punycode: 2.3.1
4397 +
4398 + which-boxed-primitive@1.1.1:
4399 + dependencies:
4400 + is-bigint: 1.1.0
4401 + is-boolean-object: 1.2.2
4402 + is-number-object: 1.1.1
4403 + is-string: 1.1.1
4404 + is-symbol: 1.1.1
4405 +
4406 + which-builtin-type@1.2.1:
4407 + dependencies:
4408 + call-bound: 1.0.4
4409 + function.prototype.name: 1.2.0
4410 + has-tostringtag: 1.0.2
4411 + is-async-function: 2.1.1
4412 + is-date-object: 1.1.0
4413 + is-finalizationregistry: 1.1.1
4414 + is-generator-function: 1.1.2
4415 + is-regex: 1.2.1
4416 + is-weakref: 1.1.1
4417 + isarray: 2.0.5
4418 + which-boxed-primitive: 1.1.1
4419 + which-collection: 1.0.2
4420 + which-typed-array: 1.1.22
4421 +
4422 + which-collection@1.0.2:
4423 + dependencies:
4424 + is-map: 2.0.3
4425 + is-set: 2.0.3
4426 + is-weakmap: 2.0.2
4427 + is-weakset: 2.0.4
4428 +
4429 + which-typed-array@1.1.22:
4430 + dependencies:
4431 + available-typed-arrays: 1.0.7
4432 + call-bind: 1.0.9
4433 + call-bound: 1.0.4
4434 + for-each: 0.3.5
4435 + get-proto: 1.0.1
4436 + gopd: 1.2.0
4437 + has-tostringtag: 1.0.2
4438 +
4439 + which@2.0.2:
4440 + dependencies:
4441 + isexe: 2.0.0
4442 +
4443 + word-wrap@1.2.5: {}
4444 +
4445 + world-atlas@2.0.2: {}
4446 +
4447 + yallist@3.1.1: {}
4448 +
4449 + yocto-queue@0.1.0: {}
4450 +
4451 + zod-validation-error@4.0.2(zod@4.6.2):
4452 + dependencies:
4453 + zod: 4.6.2
4454 +
4455 + zod@4.6.2: {}
4456 +
4457 + zrender@6.1.0:
4458 + dependencies:
4459 + tslib: 2.3.0
added apps/web/postcss.config.mjs +7 −0
@@ -0,0 +1,7 @@
1 +const config = {
2 + plugins: {
3 + '@tailwindcss/postcss': {},
4 + },
5 +};
6 +
7 +export default config;
added apps/web/public/logo.svg +15 −0
@@ -0,0 +1,15 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32" fill="none" stroke="#5B8DEF" stroke-width="1.75" stroke-linecap="round">
2 + <!-- InternetPressure.io — pressure-gauge glyph: dial, tick marks, needle -->
3 + <circle cx="16" cy="16" r="13" />
4 + <path d="M6.6 22.4 L8.4 21.4" />
5 + <path d="M5 16 L7 16" />
6 + <path d="M6.6 9.6 L8.4 10.6" />
7 + <path d="M11.6 5.4 L12.4 7.2" />
8 + <path d="M16 4 L16 6" />
9 + <path d="M20.4 5.4 L19.6 7.2" />
10 + <path d="M25.4 9.6 L23.6 10.6" />
11 + <path d="M27 16 L25 16" />
12 + <path d="M25.4 22.4 L23.6 21.4" />
13 + <path d="M16 16 L22.5 9.5" stroke-width="2.25" />
14 + <circle cx="16" cy="16" r="1.6" fill="#5B8DEF" stroke="none" />
15 +</svg>
added apps/web/qa/screens.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');
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/scripts/copy-maplibre-worker.mjs +20 −0
@@ -0,0 +1,20 @@
1 +#!/usr/bin/env node
2 +/**
3 + * MapLibre GL ≥ 6 is ESM-only and spawns a *module* worker resolved from `import.meta.url`
4 + * (`./maplibre-gl-worker.mjs`). Under Next/Turbopack that URL points at a chunk, so the worker 404s and no tile or
5 + * GeoJSON is ever parsed (black map). We serve the worker + its shared chunk from /public and call
6 + * `setWorkerUrl('/maplibre/maplibre-gl-worker.mjs')` before creating the map (see components/map/WorldMap.tsx).
7 + * Runs on `predev` / `prebuild` so the copy always matches the installed version.
8 + */
9 +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10 +import { createRequire } from 'node:module';
11 +import path from 'node:path';
12 +
13 +const require = createRequire(import.meta.url);
14 +const dist = path.dirname(require.resolve('maplibre-gl/package.json')) + '/dist';
15 +const out = path.resolve(import.meta.dirname, '../public/maplibre');
16 +mkdirSync(out, { recursive: true });
17 +for (const f of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) copyFileSync(path.join(dist, f), path.join(out, f));
18 +const version = JSON.parse(readFileSync(path.join(dist, '../package.json'), 'utf8')).version;
19 +writeFileSync(path.join(out, 'VERSION'), `${version}\n`);
20 +console.log(`[maplibre] worker ${version} → public/maplibre/`);
added apps/web/src/app/(site)/asn/[asn]/page.tsx +87 −0
@@ -0,0 +1,87 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { AsnBgpChart } from '@/components/detail/AsnBgpChart';
4 +import { ComponentGrid } from '@/components/detail/ComponentGrid';
5 +import { ScopeHeader } from '@/components/detail/ScopeHeader';
6 +import { ScopePressureChart } from '@/components/detail/ScopeCharts';
7 +import { TargetsTable } from '@/components/detail/Tables';
8 +import { IncidentsSection } from '@/components/incidents/IncidentsSection';
9 +import { Section, Stat } from '@/components/ui/primitives';
10 +import { apiGet, apiTry } from '@/lib/api';
11 +import { fmt, fmtDelta, fmtInt, fmtPct, fmtRatio } from '@/lib/format';
12 +import type { AsnDetail } from '@/lib/types';
13 +
14 +export const dynamic = 'force-dynamic';
15 +
16 +type Params = Promise<{ asn: string }>;
17 +
18 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
19 + const { asn } = await params;
20 + const a = await apiTry<AsnDetail>(`/api/v1/pressure/asn/${encodeURIComponent(asn)}`);
21 + if (!a) return { title: `AS${asn}` };
22 + return { title: `AS${a.asn} ${a.name} — pressure ${fmt(a.pressure)} (${a.level_label})`, description: `Independent observation of AS${a.asn} (${a.name}): pressure ${fmt(a.pressure)}, BGP churn ${fmtRatio(a.bgp.churn_ratio)}, path stability ${fmtPct(a.bgp.path_stability)}.`, alternates: { canonical: `/asn/${a.asn}` } };
23 +}
24 +
25 +export default async function AsnPage({ params }: { params: Params }) {
26 + const { asn } = await params;
27 + if (!/^\d+$/.test(asn)) {
28 + const { notFound } = await import('next/navigation');
29 + notFound();
30 + }
31 + const a = await apiGet<AsnDetail>(`/api/v1/pressure/asn/${encodeURIComponent(asn)}`);
32 + return (
33 + <div className="pb-8">
34 + <ScopeHeader
35 + kicker={`Autonomous system · ${a.country} · importance ${a.importance}/5`}
36 + title={`AS${a.asn} ${a.name}`}
37 + subtitle={<>Observed from {a.regions_observed.length} probe regions · {fmtInt(a.targets.length)} targets announced from this network</>}
38 + pressure={a.pressure}
39 + level={a.level}
40 + delta1h={a.delta_1h}
41 + trend={a.trend}
42 + confidence={a.confidence}
43 + ts={a.ts}
44 + meta={
45 + <>
46 + <span>
47 + regions{' '}
48 + {a.regions_observed.map((r) => (
49 + <Link key={r} href={`/internet/${r}`} className="mr-1 text-ink hover:text-accent">
50 + {r}
51 + </Link>
52 + ))}
53 + </span>
54 + </>
55 + }
56 + />
57 +
58 + <Section label="Components">
59 + <ComponentGrid components={a.components} />
60 + </Section>
61 +
62 + <Section label="BGP" right={<Link href="/bgp" className="hover:text-ink">global BGP dashboard →</Link>}>
63 + <div className="mb-4 grid grid-cols-3 gap-4 sm:grid-cols-6">
64 + <Stat label="prefixes 24h" value={fmtInt(a.bgp.prefixes_observed_24h)} />
65 + <Stat label="announcements 1h" value={fmtInt(a.bgp.announcements_1h)} />
66 + <Stat label="withdrawals 1h" value={fmtInt(a.bgp.withdrawals_1h)} />
67 + <Stat label="churn ratio" value={<span style={{ color: a.bgp.churn_ratio >= 2 ? 'var(--p-high)' : undefined }}>{fmtRatio(a.bgp.churn_ratio)}</span>} sub="vs baseline" />
68 + <Stat label="origin changes 1h" value={<span style={{ color: a.bgp.origin_changes_1h > 0 ? 'var(--p-elevated)' : undefined }}>{fmtInt(a.bgp.origin_changes_1h)}</span>} />
69 + <Stat label="path stability" value={<span style={{ color: a.bgp.path_stability < 0.9 ? 'var(--p-stressed)' : undefined }}>{fmtPct(a.bgp.path_stability)}</span>} />
70 + </div>
71 + <AsnBgpChart series={a.bgp.series_24h} />
72 + </Section>
73 +
74 + <Section label="24 h pressure">
75 + <ScopePressureChart series={a.history_24h} height={220} />
76 + </Section>
77 +
78 + <Section label="Incidents involving this ASN">
79 + <IncidentsSection incidents={a.incidents} />
80 + </Section>
81 +
82 + <Section label="Targets" right={<span>{fmtInt(a.targets.length)} targets · Δ1h {fmtDelta(a.delta_1h)}</span>}>
83 + <TargetsTable targets={a.targets} />
84 + </Section>
85 + </div>
86 + );
87 +}
added apps/web/src/app/(site)/asns/page.tsx +83 −0
@@ -0,0 +1,83 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { LevelBadge, PNum, Section } from '@/components/ui/primitives';
4 +import { apiGet } from '@/lib/api';
5 +import { fmt, fmtInt } from '@/lib/format';
6 +import { pressureColor } from '@/lib/pressure';
7 +import { Time } from '@/lib/time';
8 +import type { AsnRow } from '@/lib/types';
9 +
10 +export const dynamic = 'force-dynamic';
11 +export const metadata: Metadata = { title: 'Autonomous systems', description: 'Pressure observed per autonomous system: routing churn, latency and availability toward the networks we measure.' };
12 +
13 +export default async function AsnsPage() {
14 + const { asns, ts } = await apiGet<{ ts: string; asns: AsnRow[] }>('/api/v1/asns');
15 + const sorted = [...asns].sort((a, b) => b.pressure - a.pressure);
16 + return (
17 + <div className="pb-8">
18 + <header className="pt-6 pb-4">
19 + <p className="label">Index</p>
20 + <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Autonomous systems</h1>
21 + <p className="mt-1 text-[13px] text-ink-2">
22 + {fmtInt(asns.length)} networks with anchored targets or BGP attribution · engine <Time ts={ts} style="time" className="num" />
23 + </p>
24 + </header>
25 + <Section>
26 + <div className="scroll-x -mx-3 px-3">
27 + <table className="tbl">
28 + <thead>
29 + <tr>
30 + <th>ASN</th>
31 + <th>Name</th>
32 + <th className="hidden sm:table-cell">CC</th>
33 + <th className="r">Pressure</th>
34 + <th className="hidden md:table-cell">Level</th>
35 + <th className="r hidden sm:table-cell">Routing</th>
36 + <th className="r hidden sm:table-cell">Latency</th>
37 + <th className="r hidden md:table-cell">Avail.</th>
38 + <th className="r hidden md:table-cell">Targets</th>
39 + <th className="r hidden lg:table-cell">Prefixes</th>
40 + <th className="r hidden lg:table-cell">Imp.</th>
41 + </tr>
42 + </thead>
43 + <tbody>
44 + {sorted.map((a) => (
45 + <tr key={a.asn}>
46 + <td className="num">
47 + <Link href={`/asn/${a.asn}`} className="text-ink hover:text-accent">
48 + AS{a.asn}
49 + </Link>
50 + </td>
51 + <td>
52 + <Link href={`/asn/${a.asn}`} className="text-ink hover:text-accent">
53 + {a.name}
54 + </Link>
55 + </td>
56 + <td className="hidden text-ink-2 sm:table-cell">{a.country}</td>
57 + <td className="r">
58 + <PNum value={a.pressure} className="text-[14px]" />
59 + </td>
60 + <td className="hidden md:table-cell">
61 + <LevelBadge level={a.level} size="xs" />
62 + </td>
63 + <td className="num r hidden sm:table-cell" style={{ color: pressureColor(a.routing) }}>
64 + {fmt(a.routing, 0)}
65 + </td>
66 + <td className="num r hidden sm:table-cell" style={{ color: pressureColor(a.latency) }}>
67 + {fmt(a.latency, 0)}
68 + </td>
69 + <td className="num r hidden md:table-cell" style={{ color: pressureColor(a.availability) }}>
70 + {fmt(a.availability, 0)}
71 + </td>
72 + <td className="num r hidden text-ink-2 md:table-cell">{fmtInt(a.targets)}</td>
73 + <td className="num r hidden text-ink-2 lg:table-cell">{fmtInt(a.prefixes_observed)}</td>
74 + <td className="num r hidden text-ink-2 lg:table-cell">{a.importance}</td>
75 + </tr>
76 + ))}
77 + </tbody>
78 + </table>
79 + </div>
80 + </Section>
81 + </div>
82 + );
83 +}
added apps/web/src/app/(site)/country/[cc]/page.tsx +106 −0
@@ -0,0 +1,106 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ComponentGrid } from '@/components/detail/ComponentGrid';
4 +import { ScopeHeader } from '@/components/detail/ScopeHeader';
5 +import { ScopePressureChart } from '@/components/detail/ScopeCharts';
6 +import { LatencyMatrixTable, LinkList, ProbesTable, TargetsTable } from '@/components/detail/Tables';
7 +import { IncidentsSection } from '@/components/incidents/IncidentsSection';
8 +import { Section, Stat } from '@/components/ui/primitives';
9 +import { apiGet, apiTry } from '@/lib/api';
10 +import { fmt, fmtDelta, fmtInt, fmtPct } from '@/lib/format';
11 +import type { CountryDetailResponse, Latency } from '@/lib/types';
12 +
13 +export const dynamic = 'force-dynamic';
14 +
15 +type Params = Promise<{ cc: string }>;
16 +
17 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
18 + const { cc } = await params;
19 + const c = await apiTry<CountryDetailResponse>(`/api/v1/pressure/country/${encodeURIComponent(cc.toUpperCase())}`);
20 + if (!c) return { title: 'Country' };
21 + return { title: `${c.name} — Internet pressure ${fmt(c.pressure)} (${c.level_label})`, description: `Live Internet pressure observed for ${c.name}: ${fmt(c.pressure)} ${c.level_label}, ${fmtDelta(c.delta_1h)} over 1 h, from ${c.probes.length} probes and ${c.targets.length} anchored targets.`, alternates: { canonical: `/country/${cc.toLowerCase()}` } };
22 +}
23 +
24 +export default async function CountryPage({ params }: { params: Params }) {
25 + const { cc } = await params;
26 + const [c, latency] = await Promise.all([apiGet<CountryDetailResponse>(`/api/v1/pressure/country/${encodeURIComponent(cc.toUpperCase())}`), apiTry<Latency>('/api/v1/latency')]);
27 + const first = c.history_24h.points[0]?.pressure;
28 + const change24 = first != null ? c.pressure - first : null;
29 + const matrix = (latency?.matrix ?? []).filter((m) => m.from === c.region || m.to === c.region);
30 + return (
31 + <div className="pb-8">
32 + <ScopeHeader
33 + kicker={
34 + <>
35 + Country · region{' '}
36 + <Link href={`/internet/${c.region}`} className="text-accent hover:underline">
37 + {c.region}
38 + </Link>
39 + </>
40 + }
41 + title={c.name}
42 + subtitle={
43 + <>
44 + {c.probes.length} probe{c.probes.length === 1 ? '' : 's'} · {fmtInt(c.targets.length)} anchored targets · role {c.role}
45 + {!c.coverage_ok && <span className="text-warn"> · weak coverage</span>}
46 + </>
47 + }
48 + pressure={c.pressure}
49 + level={c.level}
50 + delta1h={c.delta_1h}
51 + trend={c.trend}
52 + meta={
53 + <>
54 + <span>
55 + Δ24h <span className="text-ink">{fmtDelta(change24)}</span>
56 + </span>
57 + <span>
58 + 7d median <span className="text-ink">{fmt(c.baseline_7d.median)}</span> · p90 <span className="text-ink">{fmt(c.baseline_7d.p90)}</span>
59 + </span>
60 + <span>
61 + ISO <span className="text-ink">{c.cc}</span>
62 + </span>
63 + </>
64 + }
65 + />
66 +
67 + <Section label="Components">
68 + <ComponentGrid components={c.components} />
69 + </Section>
70 +
71 + <Section label="24 h" right={<span>vs 7-day baseline</span>}>
72 + <ScopePressureChart series={c.history_24h} baseline={c.baseline_7d} height={240} />
73 + </Section>
74 +
75 + <Section label="Latency matrix" right={<span>region {c.region} pairs</span>}>
76 + <LatencyMatrixTable rows={matrix} highlight={c.region} />
77 + </Section>
78 +
79 + <Section label="Incidents">
80 + <IncidentsSection incidents={c.incidents} />
81 + </Section>
82 +
83 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
84 + <Section label="Affected / observed ASNs" className="min-w-0">
85 + <LinkList items={c.asns.map((a) => ({ key: String(a.asn), name: a.name, pressure: a.pressure, sub: `AS${a.asn}` }))} hrefFor={(k) => `/asn/${k}`} label="ASNs" />
86 + </Section>
87 + <Section label="Services" className="min-w-0">
88 + <LinkList items={c.services.map((s) => ({ key: s.slug, name: s.name, pressure: s.pressure, sub: `avail ${fmtPct(s.observed_availability_24h, 2)}` }))} hrefFor={(k) => `/service/${k}`} label="services" />
89 + </Section>
90 + </div>
91 +
92 + <Section label="Probe coverage">
93 + <div className="mb-3 grid grid-cols-3 gap-4">
94 + <Stat label="probes" value={fmtInt(c.probes.length)} />
95 + <Stat label="targets" value={fmtInt(c.targets.length)} />
96 + <Stat label="coverage" value={c.coverage_ok ? 'ok' : 'weak'} />
97 + </div>
98 + <ProbesTable probes={c.probes} compact />
99 + </Section>
100 +
101 + <Section label="Targets anchored here" right={<Link href="/targets" className="hover:text-ink">target registry →</Link>}>
102 + <TargetsTable targets={c.targets} showCategory />
103 + </Section>
104 + </div>
105 + );
106 +}
added apps/web/src/app/(site)/internet/[region]/page.tsx +98 −0
@@ -0,0 +1,98 @@
1 +import type { Metadata } from 'next';
2 +import Link from 'next/link';
3 +import { ComponentGrid } from '@/components/detail/ComponentGrid';
4 +import { ScopeHeader } from '@/components/detail/ScopeHeader';
5 +import { ScopePressureChart } from '@/components/detail/ScopeCharts';
6 +import { LatencyMatrixTable, LinkList, ProbesTable } from '@/components/detail/Tables';
7 +import { IncidentsSection } from '@/components/incidents/IncidentsSection';
8 +import { Section, Stat } from '@/components/ui/primitives';
9 +import { apiGet, apiTry } from '@/lib/api';
10 +import { fmt, fmtDelta, fmtInt, fmtPct } from '@/lib/format';
11 +import type { RegionDetailResponse } from '@/lib/types';
12 +
13 +export const dynamic = 'force-dynamic';
14 +
15 +type Params = Promise<{ region: string }>;
16 +
17 +export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
18 + const { region } = await params;
19 + const r = await apiTry<RegionDetailResponse>(`/api/v1/pressure/region/${encodeURIComponent(region)}`);
20 + if (!r) return { title: 'Region' };
21 + return { title: `${r.name} — Internet pressure ${fmt(r.pressure)} (${r.level_label})`, description: `Live Internet pressure for ${r.name}: ${fmt(r.pressure)} ${r.level_label}, ${fmtDelta(r.delta_1h)} over 1 h. ${r.probes.length} probes, ${r.targets} anchored targets.`, alternates: { canonical: `/internet/${region}` } };
22 +}
23 +
24 +export default async function RegionPage({ params }: { params: Params }) {
25 + const { region } = await params;
26 + const r = await apiGet<RegionDetailResponse>(`/api/v1/pressure/region/${encodeURIComponent(region)}`);
27 + const last24 = r.history_24h.points;
28 + const first = last24[0]?.pressure;
29 + const change24 = first != null ? r.pressure - first : null;
30 + return (
31 + <div className="pb-8">
32 + <ScopeHeader
33 + kicker={`Region · ${r.continent}`}
34 + title={r.name}
35 + subtitle={
36 + <>
37 + Source view from {r.probes.length} probe{r.probes.length === 1 ? '' : 's'} blended with the destination view toward {fmtInt(r.targets)} anchored targets · role {r.role}
38 + {!r.coverage_ok && <span className="text-warn"> · weak coverage — conclusions damped</span>}
39 + </>
40 + }
41 + pressure={r.pressure}
42 + level={r.level}
43 + delta1h={r.delta_1h}
44 + trend={r.trend}
45 + confidence={r.confidence}
46 + meta={
47 + <>
48 + <span>
49 + Δ24h <span className="text-ink">{fmtDelta(change24)}</span>
50 + </span>
51 + <span>
52 + 7d median <span className="text-ink">{fmt(r.baseline_7d.median)}</span> · p90 <span className="text-ink">{fmt(r.baseline_7d.p90)}</span>
53 + </span>
54 + <span>
55 + centroid <span className="text-ink">{r.lat}, {r.lon}</span>
56 + </span>
57 + </>
58 + }
59 + />
60 +
61 + <Section label="Components">
62 + <ComponentGrid components={r.components} />
63 + {r.components.routing == null && <p className="mt-2 text-[11.5px] text-ink-3">Routing is not attributable to this region (no ASN attribution) — shown as n/a, not zero.</p>}
64 + </Section>
65 +
66 + <Section label="24 h" right={<span>vs 7-day baseline</span>}>
67 + <ScopePressureChart series={r.history_24h} baseline={r.baseline_7d} height={240} />
68 + </Section>
69 +
70 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
71 + <Section label="Top ASNs" className="min-w-0">
72 + <LinkList items={r.top_asns.map((a) => ({ key: String(a.asn), name: a.name, pressure: a.pressure, sub: `AS${a.asn}` }))} hrefFor={(k) => `/asn/${k}`} label="ASNs" />
73 + </Section>
74 + <Section label="Services observed" className="min-w-0">
75 + <LinkList items={r.top_services.map((s) => ({ key: s.slug, name: s.name, pressure: s.pressure, sub: `avail ${fmtPct(s.observed_availability_24h, 2)}` }))} hrefFor={(k) => `/service/${k}`} label="services" />
76 + </Section>
77 + </div>
78 +
79 + <Section label="Latency matrix" right={<span>pairs from/to this region · z vs baseline</span>}>
80 + <LatencyMatrixTable rows={r.matrix} highlight={r.id} />
81 + </Section>
82 +
83 + <Section label="Incidents" right={<Link href="/incidents" className="hover:text-ink">all incidents →</Link>}>
84 + <IncidentsSection incidents={r.incidents} />
85 + </Section>
86 +
87 + <Section label="Probe coverage">
88 + <div className="mb-3 grid grid-cols-3 gap-4 sm:grid-cols-4">
89 + <Stat label="probes" value={fmtInt(r.probes.length)} />
90 + <Stat label="targets" value={fmtInt(r.targets)} />
91 + <Stat label="confidence" value={`${Math.round(r.confidence * 100)} %`} />
92 + <Stat label="coverage" value={r.coverage_ok ? 'ok' : 'weak'} />
93 + </div>
94 + <ProbesTable probes={r.probes} compact />
95 + </Section>
96 + </div>
97 + );
98 +}
added apps/web/src/app/(site)/layout.tsx +21 −0
@@ -0,0 +1,21 @@
1 +import { Footer } from '@/components/chrome/Footer';
2 +import { Header } from '@/components/chrome/Header';
3 +import { apiTry } from '@/lib/api';
4 +import { LiveProvider } from '@/lib/live';
5 +import type { GlobalPressure, Ticker } from '@/lib/types';
6 +
7 +export const dynamic = 'force-dynamic';
8 +
9 +/** Public chrome. One SSE connection per page lives here; the initial state is the SSR value from the same API. */
10 +export default async function SiteLayout({ children }: { children: React.ReactNode }) {
11 + const [global, ticker] = await Promise.all([apiTry<GlobalPressure>('/api/v1/pressure/global'), apiTry<Ticker>('/api/v1/ticker')]);
12 + return (
13 + <LiveProvider initial={{ global, ticker }}>
14 + <Header />
15 + <main id="main" className="mx-auto w-full max-w-[1440px] flex-1 px-3 sm:px-5">
16 + {children}
17 + </main>
18 + <Footer />
19 + </LiveProvider>
20 + );
21 +}
added apps/web/src/app/(site)/page.tsx +98 −0
@@ -0,0 +1,98 @@
1 +import Link from 'next/link';
2 +import { HistoryChart } from '@/components/charts/HistoryChart';
3 +import { Gauge } from '@/components/gauge/Gauge';
4 +import { Clock } from '@/components/home/Clock';
5 +import { ComponentRows } from '@/components/home/ComponentRows';
6 +import { Fronts } from '@/components/home/Fronts';
7 +import { IncidentsList } from '@/components/home/IncidentsList';
8 +import { ProbeStrip } from '@/components/home/ProbeStrip';
9 +import { RegionsTable } from '@/components/home/RegionsTable';
10 +import { Ticker } from '@/components/home/Ticker';
11 +import { MapIsland } from '@/components/map/MapIsland';
12 +import { Section } from '@/components/ui/primitives';
13 +import { apiTry } from '@/lib/api';
14 +import { TAGLINE } from '@/lib/site';
15 +import type { Country, Front, GlobalPressure, History, IncidentList, Latency, Probe, Region, Ticker as TickerT } from '@/lib/types';
16 +
17 +export const dynamic = 'force-dynamic';
18 +
19 +export default async function HomePage() {
20 + const [global, ticker, regions, countries, fronts, incidents, probes, history, latency] = await Promise.all([
21 + apiTry<GlobalPressure>('/api/v1/pressure/global'),
22 + apiTry<TickerT>('/api/v1/ticker'),
23 + apiTry<{ regions: Region[] }>('/api/v1/pressure/regions'),
24 + apiTry<{ countries: Country[] }>('/api/v1/pressure/countries'),
25 + apiTry<{ fronts: Front[] }>('/api/v1/fronts'),
26 + apiTry<IncidentList>('/api/v1/incidents?status=active&limit=20'),
27 + apiTry<{ probes: Probe[] }>('/api/v1/probes'),
28 + apiTry<History>('/api/v1/pressure/history?scope_type=global&range=24h'),
29 + apiTry<Latency>('/api/v1/latency'),
30 + ]);
31 +
32 + return (
33 + <div className="pb-8">
34 + {/* ---- above the fold */}
35 + <div className="grid-texture absolute inset-x-0 top-[var(--header-h)] -z-10 h-[520px]" aria-hidden="true" />
36 + <section className="grid grid-cols-[minmax(0,1fr)] gap-8 pt-6 lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] lg:gap-12 lg:pt-8">
37 + <div className="min-w-0">
38 + <p className="mb-6 text-[12.5px] text-ink-2">
39 + <span className="text-ink">InternetPressure.io</span> — {TAGLINE}.
40 + </p>
41 + <Gauge initial={global} />
42 + </div>
43 + <div className="min-w-0 lg:pt-8">
44 + <p className="label mb-2">Components</p>
45 + <ComponentRows initial={global} />
46 + <p className="mt-2 text-[11px] text-ink-3">
47 + Score 0–100 per component, weighted into the index. Every row decomposes into signals in the{' '}
48 + <Link href="/methodology" className="text-accent hover:underline">
49 + methodology
50 + </Link>
51 + .
52 + </p>
53 + </div>
54 + </section>
55 +
56 + <Section
57 + label="Live map"
58 + right={
59 + <>
60 + {countries?.countries.length ?? 0} countries observed · {probes?.probes.length ?? 0} probes · {fronts?.fronts.length ?? 0} fronts
61 + </>
62 + }
63 + className="mt-6"
64 + >
65 + <MapIsland initial={{ regions: regions?.regions ?? null, countries: countries?.countries ?? null, probes: probes?.probes ?? null, fronts: fronts?.fronts ?? null, incidents: incidents?.incidents ?? null, matrix: latency?.matrix ?? null }} />
66 + </Section>
67 +
68 + <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 lg:grid-cols-2">
69 + <Section label="Active Pressure Fronts" right={<Link href="/methodology#fronts" className="hover:text-ink">what is a front?</Link>} className="min-w-0">
70 + <Fronts initial={fronts?.fronts ?? null} />
71 + </Section>
72 + <Section label="Active incidents" right={<Link href="/incidents" className="hover:text-ink">all incidents →</Link>} className="min-w-0">
73 + <IncidentsList initial={incidents?.incidents ?? null} />
74 + </Section>
75 + </div>
76 +
77 + <Section label="Live ticker">
78 + <Ticker initial={ticker} />
79 + </Section>
80 +
81 + <Section label="Global Internet Clock">
82 + <Clock initial={ticker} />
83 + </Section>
84 +
85 + <Section label="Regions" right={<span>source view (from probes) blended with destination view (toward anchored targets)</span>}>
86 + <RegionsTable initial={regions?.regions ?? null} />
87 + </Section>
88 +
89 + <Section label="24 h history" right={<Link href="/history" className="hover:text-ink">history explorer →</Link>}>
90 + <HistoryChart initial={history} scopeType="global" height={300} />
91 + </Section>
92 +
93 + <Section label="Probe network" right={<Link href="/probes" className="hover:text-ink">all probes →</Link>}>
94 + <ProbeStrip probes={probes?.probes ?? null} />
95 + </Section>
96 + </div>
97 + );
98 +}
added apps/web/src/app/admin/layout.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import type { Metadata } from 'next';
2 +import { AdminShell } from '@/components/admin/AdminShell';
3 +
4 +export const metadata: Metadata = { title: 'Admin', robots: { index: false, follow: false } };
5 +export const dynamic = 'force-dynamic';
6 +
7 +/** Separate layout: no public chrome, no SSE. Everything under /admin is a client island hitting /api/admin/*. */
8 +export default function AdminLayout({ children }: { children: React.ReactNode }) {
9 + return <AdminShell>{children}</AdminShell>;
10 +}
added apps/web/src/app/error.tsx +22 −0
@@ -0,0 +1,22 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +
5 +/** Rendering/API failure. Framed as OUR failure (spec §57) — never as an Internet event. */
6 +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
7 + return (
8 + <div className="mx-auto flex min-h-[60vh] max-w-[720px] flex-col items-start justify-center gap-4 px-5 py-16">
9 + <p className="label text-warn">Instrument error</p>
10 + <h1 className="text-2xl font-medium tracking-tight">The observatory could not render this page.</h1>
11 + <p className="text-ink-2">This is a failure of our own pipeline or API, not a measurement of the Internet. {error.digest && <span className="num text-ink-3">ref {error.digest}</span>}</p>
12 + <div className="flex gap-4 text-sm">
13 + <button type="button" onClick={reset} className="rounded-[4px] border border-line px-3 py-1.5 text-ink hover:border-line-2">
14 + Retry
15 + </button>
16 + <Link href="/" className="py-1.5 text-accent hover:underline">
17 + Back to the gauge
18 + </Link>
19 + </div>
20 + </div>
21 + );
22 +}
added apps/web/src/app/globals.css +240 −0
@@ -0,0 +1,240 @@
1 +@import 'tailwindcss';
2 +
3 +@custom-variant dark (&:where(.dark, .dark *));
4 +
5 +/*
6 + InternetPressure.io — an instrument, not a website.
7 + Near-black planes, hairline rules, tabular numerals. Colour is reserved for the pressure scale.
8 +*/
9 +:root {
10 + color-scheme: dark;
11 + --bg: #070a0f;
12 + --panel: #0c1117;
13 + --panel-2: #10161e;
14 + --line: #1b2430;
15 + --line-2: #243040;
16 + --ink: #e6edf3;
17 + --ink-2: #8b98a5;
18 + --ink-3: #5b6875;
19 + --accent: #5b8def;
20 + --accent-soft: rgba(91, 141, 239, 0.14);
21 + --neutral: #0f151d;
22 +
23 + --p-calm: #4cc9f0;
24 + --p-normal: #7fb77e;
25 + --p-elevated: #e9c46a;
26 + --p-stressed: #f4a261;
27 + --p-high: #e76f51;
28 + --p-severe: #d62828;
29 + --p-extreme: #f72585;
30 +
31 + --ok: #7fb77e;
32 + --warn: #e9c46a;
33 + --bad: #e76f51;
34 +
35 + --radius: 4px;
36 + --header-h: 52px;
37 +}
38 +
39 +/* Graceful light fallback only when the OS asks for it AND the dark class is absent (dark is forced by default). */
40 +@media (prefers-color-scheme: light) {
41 + :root:not(.dark) {
42 + color-scheme: light;
43 + --bg: #f5f7fa;
44 + --panel: #ffffff;
45 + --panel-2: #eef2f6;
46 + --line: #d5dce4;
47 + --line-2: #c2ccd7;
48 + --ink: #0c1117;
49 + --ink-2: #4d5a68;
50 + --ink-3: #7b8794;
51 + --neutral: #e3e8ee;
52 + }
53 +}
54 +
55 +@theme inline {
56 + --color-bg: var(--bg);
57 + --color-panel: var(--panel);
58 + --color-panel-2: var(--panel-2);
59 + --color-line: var(--line);
60 + --color-line-2: var(--line-2);
61 + --color-ink: var(--ink);
62 + --color-ink-2: var(--ink-2);
63 + --color-ink-3: var(--ink-3);
64 + --color-accent: var(--accent);
65 + --color-accent-soft: var(--accent-soft);
66 + --color-neutral: var(--neutral);
67 + --color-ok: var(--ok);
68 + --color-warn: var(--warn);
69 + --color-bad: var(--bad);
70 + --color-calm: var(--p-calm);
71 + --color-normal: var(--p-normal);
72 + --color-elevated: var(--p-elevated);
73 + --color-stressed: var(--p-stressed);
74 + --color-high: var(--p-high);
75 + --color-severe: var(--p-severe);
76 + --color-extreme: var(--p-extreme);
77 + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
78 + --font-mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace;
79 + --radius-sm: 2px;
80 + --radius-md: 4px;
81 +}
82 +
83 +@layer base {
84 + html {
85 + background: var(--bg);
86 + color: var(--ink);
87 + -webkit-text-size-adjust: 100%;
88 + scrollbar-color: var(--line-2) transparent;
89 + }
90 + body {
91 + font-family: var(--font-sans);
92 + font-feature-settings: 'tnum' 1, 'cv11' 1;
93 + font-size: 14px;
94 + line-height: 1.45;
95 + background: var(--bg);
96 + color: var(--ink);
97 + }
98 + * {
99 + border-color: var(--line);
100 + }
101 + :focus-visible {
102 + outline: 2px solid var(--accent);
103 + outline-offset: 2px;
104 + }
105 + ::selection {
106 + background: rgba(91, 141, 239, 0.35);
107 + }
108 + a {
109 + text-decoration: none;
110 + }
111 + table {
112 + border-collapse: collapse;
113 + }
114 + button,
115 + input,
116 + select,
117 + textarea {
118 + font: inherit;
119 + color: inherit;
120 + }
121 + svg {
122 + display: block;
123 + }
124 +}
125 +
126 +@utility num {
127 + font-family: var(--font-mono);
128 + font-variant-numeric: tabular-nums;
129 + letter-spacing: -0.01em;
130 +}
131 +@utility label {
132 + font-size: 10.5px;
133 + letter-spacing: 0.12em;
134 + text-transform: uppercase;
135 + color: var(--ink-2);
136 + font-weight: 500;
137 +}
138 +@utility hairline {
139 + border-top: 1px solid var(--line);
140 +}
141 +@utility panel {
142 + background: var(--panel);
143 + border: 1px solid var(--line);
144 + border-radius: var(--radius);
145 +}
146 +@utility grid-texture {
147 + background-image: linear-gradient(to right, rgba(139, 152, 165, 0.06) 1px, transparent 1px), linear-gradient(to bottom, rgba(139, 152, 165, 0.06) 1px, transparent 1px);
148 + background-size: 8px 8px;
149 + mask-image: radial-gradient(ellipse at 40% 30%, rgba(0, 0, 0, 0.9), transparent 75%);
150 +}
151 +@utility scroll-x {
152 + overflow-x: auto;
153 + scrollbar-width: thin;
154 + -webkit-overflow-scrolling: touch;
155 +}
156 +@utility snap-row {
157 + display: flex;
158 + gap: 8px;
159 + overflow-x: auto;
160 + scroll-snap-type: x mandatory;
161 + scrollbar-width: none;
162 + padding-bottom: 2px;
163 +}
164 +@utility snap-item {
165 + scroll-snap-align: start;
166 + flex: 0 0 auto;
167 +}
168 +
169 +@layer components {
170 +/* dense typographic tables */
171 +.tbl {
172 + width: 100%;
173 + font-size: 12.5px;
174 +}
175 +.tbl th {
176 + font-size: 10.5px;
177 + letter-spacing: 0.1em;
178 + text-transform: uppercase;
179 + color: var(--ink-2);
180 + font-weight: 500;
181 + text-align: left;
182 + padding: 6px 8px;
183 + border-bottom: 1px solid var(--line);
184 + white-space: nowrap;
185 +}
186 +.tbl td {
187 + padding: 6px 8px;
188 + border-bottom: 1px solid var(--line);
189 + vertical-align: middle;
190 + white-space: nowrap;
191 +}
192 +.tbl tr:last-child td {
193 + border-bottom: 0;
194 +}
195 +.tbl tbody tr:hover td {
196 + background: var(--panel-2);
197 +}
198 +.tbl .r {
199 + text-align: right;
200 +}
201 +.tbl th.r {
202 + text-align: right;
203 +}
204 +
205 +/* Pressure Front arcs: dash offset animates only while a front is developing/active (class toggled by data). */
206 +@keyframes front-flow {
207 + to {
208 + stroke-dashoffset: -24;
209 + }
210 +}
211 +
212 +/* MapLibre chrome */
213 +.maplibregl-ctrl-attrib {
214 + font-size: 10px !important;
215 + background: rgba(7, 10, 15, 0.7) !important;
216 + color: var(--ink-3) !important;
217 +}
218 +.maplibregl-ctrl-attrib a {
219 + color: var(--ink-2) !important;
220 +}
221 +.maplibregl-ctrl-group {
222 + background: var(--panel) !important;
223 + border: 1px solid var(--line) !important;
224 + border-radius: var(--radius) !important;
225 + box-shadow: none !important;
226 +}
227 +.maplibregl-ctrl-group button {
228 + filter: invert(0.85);
229 +}
230 +.maplibregl-canvas {
231 + outline: none;
232 +}
233 +}
234 +
235 +@media (prefers-reduced-motion: reduce) {
236 + * {
237 + animation-duration: 0.01ms !important;
238 + transition-duration: 0.01ms !important;
239 + }
240 +}
added apps/web/src/app/icon.svg +9 −0
@@ -0,0 +1,9 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2 + <rect width="32" height="32" rx="4" fill="#070A0F" />
3 + <g fill="none" stroke="#5B8DEF" stroke-width="1.9" stroke-linecap="round">
4 + <circle cx="16" cy="16" r="11.5" />
5 + <path d="M7.7 20.8 L9.3 19.9" /><path d="M6.5 16 L8.3 16" /><path d="M7.7 11.2 L9.3 12.1" /><path d="M12 6.9 L12.7 8.6" /><path d="M16 5.5 L16 7.3" /><path d="M20 6.9 L19.3 8.6" /><path d="M24.3 11.2 L22.7 12.1" /><path d="M25.5 16 L23.7 16" /><path d="M24.3 20.8 L22.7 19.9" />
6 + <path d="M16 16 L21.6 10.4" stroke-width="2.4" />
7 + </g>
8 + <circle cx="16" cy="16" r="1.7" fill="#5B8DEF" />
9 +</svg>
added apps/web/src/app/layout.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import type { Metadata, Viewport } from 'next';
2 +import { GeistMono } from 'geist/font/mono';
3 +import { GeistSans } from 'geist/font/sans';
4 +import './globals.css';
5 +import { TimeProvider } from '@/lib/time';
6 +import { DESCRIPTION, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
7 +
8 +export const metadata: Metadata = {
9 + metadataBase: new URL(SITE_URL),
10 + title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s · ${SITE_NAME}` },
11 + description: DESCRIPTION,
12 + applicationName: SITE_NAME,
13 + robots: { index: true, follow: true },
14 + alternates: { canonical: '/' },
15 + openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
16 + twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
17 + icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }] },
18 +};
19 +
20 +export const viewport: Viewport = {
21 + width: 'device-width',
22 + initialScale: 1,
23 + viewportFit: 'cover',
24 + themeColor: '#070A0F',
25 + colorScheme: 'dark',
26 +};
27 +
28 +export default function RootLayout({ children }: { children: React.ReactNode }) {
29 + return (
30 + <html lang="en" className={`dark ${GeistSans.variable} ${GeistMono.variable} h-full antialiased`}>
31 + <body className="flex min-h-full flex-col">
32 + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-[3px] focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-bg">
33 + Skip to content
34 + </a>
35 + <TimeProvider>{children}</TimeProvider>
36 + </body>
37 + </html>
38 + );
39 +}
added apps/web/src/app/not-found.tsx +16 −0
@@ -0,0 +1,16 @@
1 +import Link from 'next/link';
2 +import { Logo } from '@/components/chrome/Logo';
3 +
4 +export default function NotFound() {
5 + return (
6 + <div className="mx-auto flex min-h-[60vh] max-w-[720px] flex-col items-start justify-center gap-4 px-5 py-16">
7 + <Logo size={28} />
8 + <p className="label">404 · not observed</p>
9 + <h1 className="text-2xl font-medium tracking-tight">This scope is not part of the observatory.</h1>
10 + <p className="text-ink-2">We only publish pages for countries, ASNs, services and incidents where we hold real measurements. Nothing is pretended.</p>
11 + <Link href="/" className="text-accent hover:underline">
12 + ← Back to the gauge
13 + </Link>
14 + </div>
15 + );
16 +}
added apps/web/src/app/opengraph-image.tsx +60 −0
@@ -0,0 +1,60 @@
1 +import { ImageResponse } from 'next/og';
2 +import { fmt, fmtDelta } from '@/lib/format';
3 +import { levelById, pressureColor } from '@/lib/pressure';
4 +import { TAGLINE } from '@/lib/site';
5 +import type { GlobalPressure } from '@/lib/types';
6 +
7 +export const runtime = 'nodejs';
8 +export const dynamic = 'force-dynamic';
9 +export const alt = 'Global Internet Pressure right now';
10 +export const size = { width: 1200, height: 630 };
11 +export const contentType = 'image/png';
12 +
13 +/** OG image rendered from the live index (real API data, never a fixture). Falls back to an unknown state. */
14 +export default async function OpenGraphImage() {
15 + let g: GlobalPressure | null = null;
16 + try {
17 + const res = await fetch((process.env.API_URL_INTERNAL ?? process.env.API_URL ?? 'http://127.0.0.1:8352') + '/api/v1/pressure/global', { cache: 'no-store' });
18 + if (res.ok) g = (await res.json()) as GlobalPressure;
19 + } catch {
20 + g = null;
21 + }
22 + const color = g ? pressureColor(g.level) : '#8B98A5';
23 + const degraded = !g || g.internal_status !== 'ok' || g.stale;
24 + return new ImageResponse(
25 + (
26 + <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '56px 72px', background: '#070A0F', color: '#E6EDF3', fontFamily: 'sans-serif', position: 'relative' }}>
27 + <div style={{ position: 'absolute', left: 120, top: 80, width: 700, height: 500, borderRadius: 9999, background: `radial-gradient(circle at 40% 45%, ${color}22, #070A0F00 70%)`, display: 'flex' }} />
28 + <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
29 + <svg width="44" height="44" viewBox="0 0 32 32" fill="none" stroke="#5B8DEF" strokeWidth="1.75" strokeLinecap="round">
30 + <circle cx="16" cy="16" r="13" />
31 + <path d="M16 4 L16 6" />
32 + <path d="M5 16 L7 16" />
33 + <path d="M27 16 L25 16" />
34 + <path d="M16 16 L22.5 9.5" strokeWidth="2.25" />
35 + <circle cx="16" cy="16" r="1.6" fill="#5B8DEF" stroke="none" />
36 + </svg>
37 + <div style={{ display: 'flex', fontSize: 30, letterSpacing: -0.5 }}>
38 + InternetPressure<span style={{ color: '#5B6875' }}>.io</span>
39 + </div>
40 + </div>
41 + <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
42 + <div style={{ fontSize: 20, letterSpacing: 4, color: '#8B98A5', display: 'flex' }}>GLOBAL INTERNET PRESSURE</div>
43 + <div style={{ display: 'flex', alignItems: 'baseline', gap: 36 }}>
44 + <div style={{ fontSize: 220, fontWeight: 600, letterSpacing: -10, lineHeight: 1, color: degraded ? '#8B98A5' : '#E6EDF3', display: 'flex' }}>{g ? fmt(g.pressure) : '—'}</div>
45 + <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
46 + <div style={{ fontSize: 44, letterSpacing: 6, color, display: 'flex' }}>{g ? (levelById(g.level)?.short ?? g.level).toUpperCase() : 'UNAVAILABLE'}</div>
47 + {g && <div style={{ fontSize: 30, color: '#8B98A5', display: 'flex' }}>{`${g.trend === 'rising' ? '↑' : g.trend === 'falling' ? '↓' : '→'} ${fmtDelta(g.delta_1h)} / 1h`}</div>}
48 + {degraded && <div style={{ fontSize: 22, color: '#E9C46A', display: 'flex' }}>Instrument degraded — score frozen</div>}
49 + </div>
50 + </div>
51 + </div>
52 + <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 20, color: '#8B98A5' }}>
53 + <div style={{ display: 'flex' }}>{TAGLINE}</div>
54 + {g && <div style={{ display: 'flex' }}>{`${g.coverage.probes_active} probes · ${g.coverage.probe_regions} regions · ${g.coverage.targets} targets · confidence ${Math.round(g.confidence * 100)} %`}</div>}
55 + </div>
56 + </div>
57 + ),
58 + { ...size },
59 + );
60 +}
added apps/web/src/app/robots.ts +10 −0
@@ -0,0 +1,10 @@
1 +import type { MetadataRoute } from 'next';
2 +import { SITE_URL } from '@/lib/site';
3 +
4 +export default function robots(): MetadataRoute.Robots {
5 + return {
6 + rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api/'] }],
7 + sitemap: `${SITE_URL}/sitemap.xml`,
8 + host: SITE_URL,
9 + };
10 +}
added apps/web/src/app/sitemap.ts +36 −0
@@ -0,0 +1,36 @@
1 +import type { MetadataRoute } from 'next';
2 +import { apiTry } from '@/lib/api';
3 +import { SITE_URL } from '@/lib/site';
4 +import type { AsnRow, Country, HistorySummary, IncidentList, Region, ServiceRow } from '@/lib/types';
5 +
6 +export const dynamic = 'force-dynamic';
7 +
8 +export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
9 + const [countries, regions, asns, services, incidents, history] = await Promise.all([
10 + apiTry<{ countries: Country[] }>('/api/v1/pressure/countries'),
11 + apiTry<{ regions: Region[] }>('/api/v1/pressure/regions'),
12 + apiTry<{ asns: AsnRow[] }>('/api/v1/asns'),
13 + apiTry<{ services: ServiceRow[] }>('/api/v1/services'),
14 + apiTry<IncidentList>('/api/v1/incidents?status=all&limit=500'),
15 + apiTry<HistorySummary>('/api/v1/history/summary'),
16 + ]);
17 + const now = new Date();
18 + const u = (p: string, priority = 0.6, changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'] = 'hourly'): MetadataRoute.Sitemap[number] => ({ url: `${SITE_URL}${p}`, lastModified: now, changeFrequency, priority });
19 + const out: MetadataRoute.Sitemap = [
20 + u('/', 1, 'always'),
21 + ...['/incidents', '/services', '/asns', '/routes', '/bgp', '/probes', '/targets', '/history'].map((p) => u(p, 0.7)),
22 + u('/methodology', 0.5, 'monthly'),
23 + u('/api', 0.5, 'monthly'),
24 + ];
25 + for (const c of countries?.countries ?? []) out.push(u(`/country/${c.cc.toLowerCase()}`, 0.8));
26 + for (const r of regions?.regions ?? []) out.push(u(`/internet/${r.id}`, 0.7));
27 + for (const a of asns?.asns ?? []) out.push(u(`/asn/${a.asn}`, 0.6));
28 + for (const s of services?.services ?? []) out.push(u(`/service/${s.slug}`, 0.7));
29 + for (const i of incidents?.incidents ?? []) out.push(u(`/event/${i.slug}`, 0.6, i.status === 'resolved' ? 'yearly' : 'hourly'));
30 + for (const m of history?.available_months ?? []) {
31 + const [y, mo] = m.split('-');
32 + if (y) out.push(u(`/history/${y}`, 0.5, 'weekly'));
33 + if (y && mo) out.push(u(`/history/${y}/${Number(mo)}`, 0.5, 'weekly'));
34 + }
35 + return out;
36 +}
added apps/web/src/components/admin/AdminShell.tsx +2 −0
@@ -0,0 +1,2 @@
1 +'use client';
2 +export function AdminShell({ children }: { children: React.ReactNode }) { return <div>{children}</div>; }
added apps/web/src/components/charts/HistoryChart.tsx +147 −0
@@ -0,0 +1,147 @@
1 +'use client';
2 +
3 +import { useEffect, useMemo, useState } from 'react';
4 +import { fmt } from '@/lib/format';
5 +import { COMPONENT_LABEL, COMPONENT_ORDER } from '@/lib/pressure';
6 +import { useTime } from '@/lib/time';
7 +import type { ComponentId, History, HistoryRange } from '@/lib/types';
8 +import { AXIS_STYLE, TOOLTIP_STYLE, levelMarkArea, type EChartsOption, type LineSeriesOption } from './echarts';
9 +import { useEChart } from './useEChart';
10 +
11 +const COMP_COLORS: Record<ComponentId, string> = {
12 + routing: '#5B8DEF',
13 + latency: '#E9C46A',
14 + dns: '#4CC9F0',
15 + availability: '#E76F51',
16 + http_tls: '#F4A261',
17 + path: '#B497F0',
18 + corroboration: '#8B98A5',
19 +};
20 +const RANGES: HistoryRange[] = ['1h', '6h', '24h', '7d', '30d'];
21 +
22 +/**
23 + * Pressure history: area of the scope pressure with level bands, optional component lines, range switcher.
24 + * `initial` is the SSR 24h response; other ranges are fetched client-side (server-side aggregation, spec §51).
25 + */
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 + const [range, setRange] = useState<HistoryRange>(initial?.range ?? '24h');
28 + const [data, setData] = useState<History | null>(initial);
29 + const [loading, setLoading] = useState(false);
30 + const [comps, setComps] = useState<Set<ComponentId>>(new Set());
31 + const { mode, format } = useTime();
32 +
33 + useEffect(() => {
34 + if (initial && range === initial.range) {
35 + setData(initial);
36 + return;
37 + }
38 + const ctrl = new AbortController();
39 + setLoading(true);
40 + const qs = new URLSearchParams({ scope_type: scopeType, range });
41 + if (scopeId) qs.set('scope_id', scopeId);
42 + fetch(`/api/v1/pressure/history?${qs}`, { signal: ctrl.signal })
43 + .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));
47 + return () => ctrl.abort();
48 + }, [range, scopeType, scopeId, initial]);
49 +
50 + const option = useMemo<EChartsOption | null>(() => {
51 + if (!data) return null;
52 + const ts = data.points.map((p) => new Date(p.ts).getTime());
53 + const series: LineSeriesOption[] = [
54 + {
55 + name: 'Pressure',
56 + type: 'line',
57 + data: ts.map((t, i) => [t, data.points[i]!.pressure]),
58 + showSymbol: false,
59 + smooth: false,
60 + lineStyle: { width: 1.5, color: '#E6EDF3' },
61 + areaStyle: { color: 'rgba(230,237,243,0.06)' },
62 + markArea: levelMarkArea(0.06),
63 + z: 3,
64 + },
65 + ];
66 + for (const c of comps) {
67 + series.push({ name: COMPONENT_LABEL[c], type: 'line', data: ts.map((t, i) => [t, data.points[i]!.components?.[c] ?? null]), showSymbol: false, lineStyle: { width: 1, color: COMP_COLORS[c] }, connectNulls: false, z: 2 });
68 + }
69 + return {
70 + animation: false,
71 + grid: { left: 36, right: 12, top: 12, bottom: 28 },
72 + tooltip: {
73 + trigger: 'axis',
74 + ...TOOLTIP_STYLE,
75 + axisPointer: { lineStyle: { color: '#243040' } },
76 + formatter: (params: unknown) => {
77 + const arr = params as { seriesName: string; value: [number, number | null]; color: string }[];
78 + if (!arr.length) return '';
79 + const t = arr[0]!.value[0];
80 + return `<div style="color:#8B98A5;margin-bottom:4px">${format(t, 'short')}</div>` + arr.map((p) => `<div><span style="display:inline-block;width:8px;height:8px;background:${p.color};margin-right:6px"></span>${p.seriesName} <b style="float:right;margin-left:12px">${p.value[1] == null ? '—' : fmt(p.value[1])}</b></div>`).join('');
81 + },
82 + },
83 + xAxis: { type: 'time', ...AXIS_STYLE, splitLine: { show: false }, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: (v: number) => format(v, range === '1h' || range === '6h' ? 'time' : range === '24h' ? 'short' : 'date').replace(' UTC', '') } },
84 + yAxis: { type: 'value', min: 0, max: 100, interval: 25, ...AXIS_STYLE },
85 + series,
86 + };
87 + // `mode` is a dependency because the axis formatter closes over the UTC/local preference.
88 + // eslint-disable-next-line react-hooks/exhaustive-deps
89 + }, [data, comps, range, mode]);
90 +
91 + const { ref } = useEChart(option);
92 +
93 + return (
94 + <div>
95 + <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
96 + <div className="flex flex-wrap items-center gap-3">
97 + {title && <span className="text-[13px] text-ink">{title}</span>}
98 + <div role="radiogroup" aria-label="Range" className="inline-flex overflow-hidden rounded-[4px] border border-line text-[10.5px]">
99 + {RANGES.map((r) => (
100 + <button key={r} type="button" role="radio" aria-checked={range === r} onClick={() => setRange(r)} className={`num px-2 py-1 ${range === r ? 'bg-panel-2 text-ink' : 'text-ink-3 hover:text-ink-2'}`}>
101 + {r}
102 + </button>
103 + ))}
104 + </div>
105 + {loading && <span className="text-[10.5px] text-ink-3">loading…</span>}
106 + </div>
107 + {showComponents && (
108 + <div className="flex flex-wrap gap-1" role="group" aria-label="Component lines">
109 + {COMPONENT_ORDER.filter((c) => c !== 'corroboration').map((c) => {
110 + const on = comps.has(c);
111 + return (
112 + <button
113 + key={c}
114 + type="button"
115 + aria-pressed={on}
116 + onClick={() =>
117 + setComps((s) => {
118 + const n = new Set(s);
119 + if (n.has(c)) n.delete(c);
120 + else n.add(c);
121 + return n;
122 + })
123 + }
124 + className={`inline-flex items-center gap-1.5 rounded-[3px] border px-1.5 py-0.5 text-[10.5px] ${on ? 'border-line-2 text-ink' : 'border-line text-ink-3 hover:text-ink-2'}`}
125 + >
126 + <span className="size-1.5 rounded-full" style={{ background: COMP_COLORS[c], opacity: on ? 1 : 0.4 }} aria-hidden="true" />
127 + {COMPONENT_LABEL[c]}
128 + </button>
129 + );
130 + })}
131 + </div>
132 + )}
133 + </div>
134 + <div ref={ref} style={{ height }} className="w-full" role="img" aria-label={`Pressure history, ${range}`} />
135 + {data && (
136 + <p className="num mt-1 flex flex-wrap gap-x-4 text-[10.5px] text-ink-3">
137 + <span>min {fmt(data.summary.min)}</span>
138 + <span>avg {fmt(data.summary.avg)}</span>
139 + <span>
140 + max {fmt(data.summary.max)} at {format(data.summary.max_ts, 'short')}
141 + </span>
142 + <span>step {data.step_seconds >= 3600 ? `${data.step_seconds / 3600} h` : data.step_seconds >= 60 ? `${data.step_seconds / 60} min` : `${data.step_seconds} s`}</span>
143 + </p>
144 + )}
145 + </div>
146 + );
147 +}
added apps/web/src/components/charts/SeriesChart.tsx +89 −0
@@ -0,0 +1,89 @@
1 +'use client';
2 +
3 +import { useMemo } from 'react';
4 +import { fmt } from '@/lib/format';
5 +import { useTime } from '@/lib/time';
6 +import { AXIS_STYLE, TOOLTIP_STYLE, levelMarkArea, type BarSeriesOption, type EChartsOption, type LineSeriesOption } from './echarts';
7 +import { useEChart } from './useEChart';
8 +
9 +export interface SeriesLine {
10 + name: string;
11 + color: string;
12 + points: { ts: string; value: number | null }[];
13 + area?: boolean;
14 + width?: number;
15 + dashed?: boolean;
16 + yAxisIndex?: number;
17 + type?: 'line' | 'bar';
18 +}
19 +
20 +/** Generic small multi-line chart (incident series vs global, ASN BGP series, target TTFB, baselines…). */
21 +export function SeriesChart({ lines, height = 220, yMax = 100, yMin = 0, bands = true, y2, markLines, unit = '' }: { lines: SeriesLine[]; height?: number; yMax?: number | 'auto'; yMin?: number; bands?: boolean; y2?: { max?: number | 'auto'; name?: string }; markLines?: { ts: string; label: string }[]; unit?: string }) {
22 + const { mode, format } = useTime();
23 + const option = useMemo<EChartsOption | null>(() => {
24 + if (!lines.length) return null;
25 + return {
26 + animation: false,
27 + grid: { left: 40, right: y2 ? 40 : 12, top: 10, bottom: 26 },
28 + tooltip: {
29 + trigger: 'axis',
30 + ...TOOLTIP_STYLE,
31 + formatter: (params: unknown) => {
32 + const arr = params as { seriesName: string; value: [number, number | null]; color: string }[];
33 + if (!arr.length) return '';
34 + return `<div style="color:#8B98A5;margin-bottom:4px">${format(arr[0]!.value[0], 'short')}</div>` + arr.map((p) => `<div><span style="display:inline-block;width:8px;height:8px;background:${p.color};margin-right:6px"></span>${p.seriesName} <b style="float:right;margin-left:12px">${p.value[1] == null ? '—' : fmt(p.value[1], Math.abs(p.value[1]) >= 100 ? 0 : 1)}${unit}</b></div>`).join('');
35 + },
36 + },
37 + xAxis: { type: 'time', ...AXIS_STYLE, splitLine: { show: false }, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: (v: number) => format(v, 'time').replace(' UTC', '').slice(0, 5) } },
38 + yAxis: [
39 + { type: 'value', min: yMin, max: yMax === 'auto' ? undefined : yMax, ...AXIS_STYLE },
40 + ...(y2 ? [{ type: 'value' as const, min: 0, max: y2.max === 'auto' ? undefined : y2.max, ...AXIS_STYLE, splitLine: { show: false }, name: y2.name, nameTextStyle: { color: '#8B98A5', fontSize: 10 } }] : []),
41 + ],
42 + series: lines.map((l, i): LineSeriesOption | BarSeriesOption => {
43 + const data = l.points.map((p) => [new Date(p.ts).getTime(), p.value]);
44 + const markLine: LineSeriesOption['markLine'] =
45 + i === 0 && markLines?.length
46 + ? {
47 + symbol: 'none',
48 + silent: true,
49 + lineStyle: { color: '#5B6875', type: 'dotted' },
50 + label: { color: '#8B98A5', fontSize: 10, formatter: (p) => String((p as { name?: string }).name ?? ''), position: 'insideEndTop' },
51 + data: markLines.map((m) => ({ xAxis: new Date(m.ts).getTime(), name: m.label })),
52 + }
53 + : undefined;
54 + if (l.type === 'bar') {
55 + return { name: l.name, type: 'bar', yAxisIndex: l.yAxisIndex ?? 0, data, itemStyle: { color: l.color, opacity: 0.8 }, barMaxWidth: 6, z: lines.length - i };
56 + }
57 + return {
58 + name: l.name,
59 + type: 'line',
60 + yAxisIndex: l.yAxisIndex ?? 0,
61 + data,
62 + showSymbol: false,
63 + lineStyle: { width: l.width ?? 1.25, color: l.color, type: l.dashed ? 'dashed' : 'solid' },
64 + itemStyle: { color: l.color },
65 + areaStyle: l.area ? { color: l.color + '14' } : undefined,
66 + connectNulls: false,
67 + z: lines.length - i,
68 + markArea: i === 0 && bands ? levelMarkArea(0.05) : undefined,
69 + markLine,
70 + };
71 + }),
72 + };
73 + // eslint-disable-next-line react-hooks/exhaustive-deps
74 + }, [lines, yMax, yMin, bands, y2, markLines, unit, mode]);
75 + const { ref } = useEChart(option);
76 + return (
77 + <div>
78 + <div ref={ref} style={{ height }} className="w-full" role="img" aria-label={lines.map((l) => l.name).join(', ')} />
79 + <ul className="mt-1 flex flex-wrap gap-x-4 text-[10.5px] text-ink-2">
80 + {lines.map((l) => (
81 + <li key={l.name} className="inline-flex items-center gap-1.5">
82 + <span className="inline-block h-[2px] w-3" style={{ background: l.color, borderTop: l.dashed ? `1px dashed ${l.color}` : undefined }} aria-hidden="true" />
83 + {l.name}
84 + </li>
85 + ))}
86 + </ul>
87 + </div>
88 + );
89 +}
added apps/web/src/components/charts/echarts.ts +45 −0
@@ -0,0 +1,45 @@
1 +'use client';
2 +
3 +import { BarChart, LineChart } from 'echarts/charts';
4 +import { DataZoomComponent, GridComponent, LegendComponent, MarkAreaComponent, MarkLineComponent, TooltipComponent } from 'echarts/components';
5 +import * as echarts from 'echarts/core';
6 +import { CanvasRenderer } from 'echarts/renderers';
7 +import type { ComposeOption } from 'echarts/core';
8 +import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts';
9 +import type { DataZoomComponentOption, GridComponentOption, LegendComponentOption, MarkAreaComponentOption, MarkLineComponentOption, TooltipComponentOption } from 'echarts/components';
10 +
11 +echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, MarkAreaComponent, MarkLineComponent, DataZoomComponent, CanvasRenderer]);
12 +
13 +export type EChartsOption = ComposeOption<LineSeriesOption | BarSeriesOption | GridComponentOption | TooltipComponentOption | LegendComponentOption | MarkAreaComponentOption | MarkLineComponentOption | DataZoomComponentOption>;
14 +
15 +export default echarts;
16 +
17 +/** Shared dark theme fragments (hairlines, mono numerals). */
18 +export const AXIS_STYLE = {
19 + axisLine: { lineStyle: { color: '#1B2430' } },
20 + axisTick: { show: false },
21 + axisLabel: { color: '#8B98A5', fontFamily: 'var(--font-geist-mono), ui-monospace, monospace', fontSize: 10.5 },
22 + splitLine: { lineStyle: { color: '#151C25' } },
23 +};
24 +export const TOOLTIP_STYLE = {
25 + backgroundColor: '#0C1117',
26 + borderColor: '#1B2430',
27 + borderWidth: 1,
28 + padding: [6, 10],
29 + textStyle: { color: '#E6EDF3', fontSize: 11.5, fontFamily: 'var(--font-geist-mono), ui-monospace, monospace' },
30 + extraCssText: 'border-radius:4px;box-shadow:none;',
31 +};
32 +export const LEVEL_BANDS: [number, number, string][] = [
33 + [0, 10, '#4CC9F0'],
34 + [10, 25, '#7FB77E'],
35 + [25, 40, '#E9C46A'],
36 + [40, 55, '#F4A261'],
37 + [55, 70, '#E76F51'],
38 + [70, 85, '#D62828'],
39 + [85, 100, '#F72585'],
40 +];
41 +export const levelMarkArea = (alpha = 0.05): NonNullable<LineSeriesOption['markArea']> => ({
42 + silent: true,
43 + data: LEVEL_BANDS.map(([lo, hi, c]) => [{ yAxis: lo, itemStyle: { color: c + Math.round(alpha * 255).toString(16).padStart(2, '0') } }, { yAxis: hi }] as [{ yAxis: number; itemStyle: { color: string } }, { yAxis: number }]),
44 +});
45 +export type { LineSeriesOption, BarSeriesOption };
added apps/web/src/components/charts/useEChart.ts +31 −0
@@ -0,0 +1,31 @@
1 +'use client';
2 +
3 +import { useEffect, useRef } from 'react';
4 +import echarts, { type EChartsOption } from './echarts';
5 +
6 +/** Mounts an ECharts instance on a div, keeps it sized with ResizeObserver, applies `option` when it changes. */
7 +export function useEChart(option: EChartsOption | null, deps: unknown[] = []) {
8 + const ref = useRef<HTMLDivElement>(null);
9 + const chartRef = useRef<echarts.ECharts | null>(null);
10 +
11 + useEffect(() => {
12 + const el = ref.current;
13 + if (!el) return;
14 + const chart = echarts.init(el, undefined, { renderer: 'canvas' });
15 + chartRef.current = chart;
16 + const ro = new ResizeObserver(() => chart.resize());
17 + ro.observe(el);
18 + return () => {
19 + ro.disconnect();
20 + chart.dispose();
21 + chartRef.current = null;
22 + };
23 + }, []);
24 +
25 + useEffect(() => {
26 + if (option && chartRef.current) chartRef.current.setOption(option, { notMerge: true, lazyUpdate: true });
27 + // eslint-disable-next-line react-hooks/exhaustive-deps
28 + }, [option, ...deps]);
29 +
30 + return { ref, chartRef };
31 +}
added apps/web/src/components/chrome/DegradedBanner.tsx +31 −0
@@ -0,0 +1,31 @@
1 +'use client';
2 +
3 +import { useDegraded } from '@/lib/live';
4 +import { Time } from '@/lib/time';
5 +
6 +/** Spec §57 — our own failure must never be read as an Internet event. Shown whenever internal_status ≠ ok or stale. */
7 +export function DegradedBanner() {
8 + const { degraded, status, reason, frozenSince } = useDegraded();
9 + if (!degraded) return null;
10 + return (
11 + <div role="status" className="border-b border-warn/40 bg-[rgba(233,196,106,0.08)] px-4 py-2 text-[12.5px] text-ink">
12 + <div className="mx-auto flex max-w-[1440px] flex-wrap items-center gap-x-3 gap-y-1">
13 + <span className="inline-flex items-center gap-2 font-medium uppercase tracking-[0.12em] text-warn">
14 + <span className="size-1.5 rounded-full bg-warn" aria-hidden="true" />
15 + Instrument degraded
16 + </span>
17 + <span className="text-ink-2">
18 + {status === 'stale' ? 'The pressure engine has not run recently.' : 'Too few fresh probes, stale BGP feed or unhealthy stores.'} The score is frozen
19 + {frozenSince && (
20 + <>
21 + {' '}
22 + since <Time ts={frozenSince} style="short" className="num text-ink" />
23 + </>
24 + )}{' '}
25 + and must not be read as an Internet event.
26 + {reason && <span className="text-ink-3"> — {reason}</span>}
27 + </span>
28 + </div>
29 + </div>
30 + );
31 +}
added apps/web/src/components/chrome/Footer.tsx +39 −0
@@ -0,0 +1,39 @@
1 +import Link from 'next/link';
2 +import { AUTHOR, CONTACT_EMAIL } from '@/lib/site';
3 +
4 +export function Footer() {
5 + return (
6 + <footer className="mt-10 border-t border-line">
7 + <div className="mx-auto flex max-w-[1440px] flex-col gap-3 px-3 py-6 text-[11.5px] text-ink-2 sm:flex-row sm:items-center sm:justify-between sm:px-5">
8 + <p className="leading-relaxed">
9 + Independent observatory · {AUTHOR} ·{' '}
10 + <a href={`mailto:${CONTACT_EMAIL}`} className="text-ink hover:text-accent">
11 + {CONTACT_EMAIL}
12 + </a>{' '}
13 + · Hosted on MacLustr (
14 + <a href="https://www.maclustr.io" rel="noopener" className="text-ink hover:text-accent">
15 + www.maclustr.io
16 + </a>
17 + )
18 + </p>
19 + <nav aria-label="Footer" className="flex flex-wrap gap-x-4 gap-y-1">
20 + <Link href="/methodology" className="hover:text-ink">
21 + Methodology
22 + </Link>
23 + <Link href="/api" className="hover:text-ink">
24 + API
25 + </Link>
26 + <Link href="/probes" className="hover:text-ink">
27 + Probes
28 + </Link>
29 + <Link href="/history" className="hover:text-ink">
30 + History
31 + </Link>
32 + <Link href="/admin" className="hover:text-ink">
33 + Admin
34 + </Link>
35 + </nav>
36 + </div>
37 + </footer>
38 + );
39 +}
added apps/web/src/components/chrome/Header.tsx +35 −0
@@ -0,0 +1,35 @@
1 +import Link from 'next/link';
2 +import { SITE_NAME } from '@/lib/site';
3 +import { DegradedBanner } from './DegradedBanner';
4 +import { LiveIndicator } from './LiveIndicator';
5 +import { Logo } from './Logo';
6 +import { NavLinks } from './NavLinks';
7 +import { Search } from './Search';
8 +import { TimeToggle } from './TimeToggle';
9 +
10 +export function Header() {
11 + return (
12 + <>
13 + <header className="sticky top-0 z-40 border-b border-line bg-bg/95 backdrop-blur-[2px]">
14 + <div className="mx-auto flex h-[var(--header-h)] max-w-[1440px] items-center gap-4 px-3 sm:px-5">
15 + <Link href="/" className="flex items-center gap-2 text-[14px] font-medium tracking-tight text-ink" aria-label={`${SITE_NAME} home`}>
16 + <Logo size={20} />
17 + <span>
18 + InternetPressure<span className="text-ink-3">.io</span>
19 + </span>
20 + </Link>
21 + <NavLinks className="hidden lg:flex" />
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" />
25 + <Search />
26 + </div>
27 + </div>
28 + <div className="border-t border-line lg:hidden">
29 + <NavLinks className="scroll-x mx-auto flex max-w-[1440px] px-3 sm:px-5" compact />
30 + </div>
31 + </header>
32 + <DegradedBanner />
33 + </>
34 + );
35 +}
added apps/web/src/components/chrome/LiveIndicator.tsx +28 −0
@@ -0,0 +1,28 @@
1 +'use client';
2 +
3 +import { useDegraded, useLive, useNow } from '@/lib/live';
4 +
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 }) {
7 + const { lastEventAt, connection } = useLive((s) => ({ lastEventAt: s.lastEventAt, connection: s.connection }));
8 + const { degraded } = useDegraded();
9 + const now = useNow(1000);
10 + const ago = lastEventAt ? Math.max(0, Math.round((now - lastEventAt) / 1000)) : null;
11 + const live = connection === 'open' && !degraded;
12 + const color = degraded ? 'var(--warn)' : connection === 'open' ? 'var(--ok)' : 'var(--ink-3)';
13 + const word = degraded ? 'DEGRADED' : connection === 'open' ? 'LIVE' : connection === 'reconnecting' ? 'RECONNECTING' : connection === 'connecting' ? 'CONNECTING' : 'OFFLINE';
14 + return (
15 + <span className={`inline-flex items-center gap-2 text-[11px] tracking-[0.1em] ${className}`} aria-live="off" suppressHydrationWarning>
16 + <span className="relative inline-flex size-1.5">
17 + <span className="size-1.5 rounded-full" style={{ background: color }} />
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 + </span>
20 + <span style={{ color }}>{word}</span>
21 + {ago != null && (
22 + <span className="num text-ink-3">
23 + · updated {ago < 60 ? `${ago} s` : `${Math.floor(ago / 60)} min`} ago
24 + </span>
25 + )}
26 + </span>
27 + );
28 +}
added apps/web/src/components/chrome/Logo.tsx +19 −0
@@ -0,0 +1,19 @@
1 +/** Pressure-gauge glyph (same drawing as public/logo.svg) — inline so it inherits colour and needs no request. */
2 +export function Logo({ size = 22, className, color = 'var(--accent)' }: { size?: number; className?: string; color?: string }) {
3 + return (
4 + <svg viewBox="0 0 32 32" width={size} height={size} fill="none" stroke={color} strokeWidth={1.75} strokeLinecap="round" className={className} aria-hidden="true">
5 + <circle cx="16" cy="16" r="13" />
6 + <path d="M6.6 22.4 L8.4 21.4" />
7 + <path d="M5 16 L7 16" />
8 + <path d="M6.6 9.6 L8.4 10.6" />
9 + <path d="M11.6 5.4 L12.4 7.2" />
10 + <path d="M16 4 L16 6" />
11 + <path d="M20.4 5.4 L19.6 7.2" />
12 + <path d="M25.4 9.6 L23.6 10.6" />
13 + <path d="M27 16 L25 16" />
14 + <path d="M25.4 22.4 L23.6 21.4" />
15 + <path d="M16 16 L22.5 9.5" strokeWidth={2.25} />
16 + <circle cx="16" cy="16" r="1.6" fill={color} stroke="none" />
17 + </svg>
18 + );
19 +}
added apps/web/src/components/chrome/NavLinks.tsx +39 −0
@@ -0,0 +1,39 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { usePathname } from 'next/navigation';
5 +
6 +const LINKS: [string, string][] = [
7 + ['/', 'Gauge'],
8 + ['/incidents', 'Incidents'],
9 + ['/services', 'Services'],
10 + ['/asns', 'ASNs'],
11 + ['/routes', 'Routes'],
12 + ['/bgp', 'BGP'],
13 + ['/probes', 'Probes'],
14 + ['/history', 'History'],
15 + ['/methodology', 'Methodology'],
16 + ['/api', 'API'],
17 +];
18 +
19 +export function NavLinks({ className = '', compact = false }: { className?: string; compact?: boolean }) {
20 + const path = usePathname();
21 + return (
22 + <nav aria-label="Primary" className={`items-center gap-1 ${className}`}>
23 + {LINKS.map(([href, label]) => {
24 + const active = href === '/' ? path === '/' : path.startsWith(href);
25 + return (
26 + <Link
27 + key={href}
28 + href={href}
29 + aria-current={active ? 'page' : undefined}
30 + className={`whitespace-nowrap rounded-[3px] px-2 text-[11.5px] tracking-[0.04em] transition-colors ${compact ? 'py-2' : 'py-1'} ${active ? 'text-ink' : 'text-ink-2 hover:text-ink'}`}
31 + style={active ? { boxShadow: 'inset 0 -1px 0 var(--accent)' } : undefined}
32 + >
33 + {label}
34 + </Link>
35 + );
36 + })}
37 + </nav>
38 + );
39 +}
added apps/web/src/components/chrome/Search.tsx +146 −0
@@ -0,0 +1,146 @@
1 +'use client';
2 +
3 +import { useRouter } from 'next/navigation';
4 +import { useCallback, useEffect, useRef, useState } from 'react';
5 +import { fmt } from '@/lib/format';
6 +import { pressureColor } from '@/lib/pressure';
7 +import type { SearchResult } from '@/lib/types';
8 +
9 +const TYPE_LABEL: Record<SearchResult['type'], string> = { country: 'Country', region: 'Region', asn: 'ASN', service: 'Service', target: 'Target', incident: 'Incident' };
10 +
11 +/** ⌘K search over /api/v1/search — countries, regions, ASNs, services, targets, incidents. */
12 +export function Search() {
13 + const [open, setOpen] = useState(false);
14 + const [q, setQ] = useState('');
15 + const [results, setResults] = useState<SearchResult[]>([]);
16 + const [active, setActive] = useState(0);
17 + const [loading, setLoading] = useState(false);
18 + const router = useRouter();
19 + const inputRef = useRef<HTMLInputElement>(null);
20 + const abortRef = useRef<AbortController | null>(null);
21 +
22 + useEffect(() => {
23 + const onKey = (e: KeyboardEvent) => {
24 + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
25 + e.preventDefault();
26 + setOpen((o) => !o);
27 + } else if (e.key === 'Escape') setOpen(false);
28 + };
29 + window.addEventListener('keydown', onKey);
30 + 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]);
40 +
41 + useEffect(() => {
42 + if (!open) return;
43 + const s = q.trim();
44 + if (!s) {
45 + setResults([]);
46 + return;
47 + }
48 + abortRef.current?.abort();
49 + const ctrl = new AbortController();
50 + abortRef.current = ctrl;
51 + setLoading(true);
52 + const t = setTimeout(() => {
53 + fetch(`/api/v1/search?q=${encodeURIComponent(s)}`, { signal: ctrl.signal })
54 + .then((r) => (r.ok ? r.json() : { results: [] }))
55 + .then((d: { results?: SearchResult[] }) => {
56 + setResults(d.results ?? []);
57 + setActive(0);
58 + })
59 + .catch(() => {})
60 + .finally(() => setLoading(false));
61 + }, 120);
62 + return () => {
63 + clearTimeout(t);
64 + ctrl.abort();
65 + };
66 + }, [q, open]);
67 +
68 + const go = useCallback(
69 + (r: SearchResult | undefined) => {
70 + if (!r) return;
71 + setOpen(false);
72 + router.push(r.href);
73 + },
74 + [router],
75 + );
76 +
77 + return (
78 + <>
79 + <button
80 + type="button"
81 + onClick={() => setOpen(true)}
82 + className="inline-flex h-7 items-center gap-2 rounded-[4px] border border-line px-2 text-[11px] text-ink-2 hover:border-line-2 hover:text-ink"
83 + aria-label="Search (Command K)"
84 + >
85 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
86 + <circle cx="11" cy="11" r="7" />
87 + <path d="m20 20-3.5-3.5" />
88 + </svg>
89 + <span className="hidden sm:inline">Search</span>
90 + <kbd className="num hidden rounded-[2px] border border-line px-1 text-[9.5px] text-ink-3 sm:inline">⌘K</kbd>
91 + </button>
92 + {open && (
93 + <div className="fixed inset-0 z-[100] flex items-start justify-center bg-bg/80 px-3 pt-[12vh]" onClick={() => setOpen(false)} role="presentation">
94 + <div role="dialog" aria-modal="true" aria-label="Search" className="panel w-full max-w-[560px] overflow-hidden" onClick={(e) => e.stopPropagation()}>
95 + <div className="flex items-center gap-2 border-b border-line px-3">
96 + <span className="text-ink-3" aria-hidden="true">
97 + /
98 + </span>
99 + <input
100 + ref={inputRef}
101 + value={q}
102 + onChange={(e) => setQ(e.target.value)}
103 + onKeyDown={(e) => {
104 + if (e.key === 'ArrowDown') {
105 + e.preventDefault();
106 + setActive((a) => Math.min(results.length - 1, a + 1));
107 + } else if (e.key === 'ArrowUp') {
108 + e.preventDefault();
109 + setActive((a) => Math.max(0, a - 1));
110 + } else if (e.key === 'Enter') go(results[active]);
111 + }}
112 + placeholder="Country, region, AS number, service, hostname, incident…"
113 + className="h-11 w-full bg-transparent text-[14px] text-ink placeholder:text-ink-3 focus:outline-none"
114 + aria-activedescendant={results[active] ? `sr-${active}` : undefined}
115 + />
116 + {loading && <span className="text-[10px] text-ink-3">…</span>}
117 + </div>
118 + <ul role="listbox" className="max-h-[50vh] overflow-y-auto">
119 + {results.map((r, i) => (
120 + <li
121 + key={`${r.type}-${r.id}`}
122 + id={`sr-${i}`}
123 + role="option"
124 + aria-selected={i === active}
125 + onMouseEnter={() => setActive(i)}
126 + onClick={() => go(r)}
127 + className={`flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px] ${i === active ? 'bg-panel-2' : ''}`}
128 + >
129 + <span className="label w-16 shrink-0">{TYPE_LABEL[r.type]}</span>
130 + <span className="flex-1 truncate text-ink">{r.label}</span>
131 + {r.pressure != null && (
132 + <span className="num text-xs" style={{ color: pressureColor(r.pressure) }}>
133 + {fmt(r.pressure)}
134 + </span>
135 + )}
136 + </li>
137 + ))}
138 + {q.trim() && !loading && results.length === 0 && <li className="px-3 py-6 text-center text-xs text-ink-3">No match in the observatory.</li>}
139 + {!q.trim() && <li className="px-3 py-4 text-xs text-ink-3">Type to search. ↑↓ to move, ⏎ to open, esc to close.</li>}
140 + </ul>
141 + </div>
142 + </div>
143 + )}
144 + </>
145 + );
146 +}
added apps/web/src/components/chrome/TimeToggle.tsx +23 −0
@@ -0,0 +1,23 @@
1 +'use client';
2 +
3 +import { useTime } from '@/lib/time';
4 +
5 +export function TimeToggle({ className = '' }: { className?: string }) {
6 + const { mode, setMode } = useTime();
7 + return (
8 + <div role="radiogroup" aria-label="Time zone" className={`inline-flex overflow-hidden rounded-[4px] border border-line text-[10.5px] tracking-[0.1em] ${className}`}>
9 + {(['utc', 'local'] as const).map((m) => (
10 + <button
11 + key={m}
12 + type="button"
13 + role="radio"
14 + aria-checked={mode === m}
15 + onClick={() => setMode(m)}
16 + className={`px-2 py-1 uppercase transition-colors ${mode === m ? 'bg-panel-2 text-ink' : 'text-ink-3 hover:text-ink-2'}`}
17 + >
18 + {m}
19 + </button>
20 + ))}
21 + </div>
22 + );
23 +}
added apps/web/src/components/detail/AsnBgpChart.tsx +17 −0
@@ -0,0 +1,17 @@
1 +'use client';
2 +
3 +import { SeriesChart } from '@/components/charts/SeriesChart';
4 +
5 +export function AsnBgpChart({ series }: { series: { ts: string; announcements: number; withdrawals: number }[] }) {
6 + return (
7 + <SeriesChart
8 + lines={[
9 + { name: 'Announcements', color: '#5B8DEF', points: series.map((p) => ({ ts: p.ts, value: p.announcements })), type: 'bar' },
10 + { name: 'Withdrawals', color: '#E76F51', points: series.map((p) => ({ ts: p.ts, value: p.withdrawals })), type: 'bar' },
11 + ]}
12 + height={180}
13 + yMax="auto"
14 + bands={false}
15 + />
16 + );
17 +}
added apps/web/src/components/detail/ComponentGrid.tsx +25 −0
@@ -0,0 +1,25 @@
1 +import { Bar } from '@/components/ui/primitives';
2 +import { fmt } from '@/lib/format';
3 +import { COMPONENT_LABEL, COMPONENT_ORDER, pressureColor } from '@/lib/pressure';
4 +import type { ComponentScores } from '@/lib/types';
5 +
6 +/** Static component score grid for scope pages. `null` = not attributable for this scope (shown, never faked). */
7 +export function ComponentGrid({ components }: { components: ComponentScores }) {
8 + const ids = COMPONENT_ORDER.filter((c) => c in components);
9 + return (
10 + <dl className="grid grid-cols-3 gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-6">
11 + {ids.map((id) => {
12 + const v = components[id];
13 + return (
14 + <div key={id} className="bg-panel px-3 py-2.5">
15 + <dt className="label truncate">{COMPONENT_LABEL[id]}</dt>
16 + <dd className="num mt-0.5 text-[22px] leading-none" style={{ color: v == null ? 'var(--ink-3)' : pressureColor(v) }} title={v == null ? 'Not attributable for this scope' : undefined}>
17 + {v == null ? 'n/a' : fmt(v)}
18 + </dd>
19 + <Bar value={v} className="mt-2" />
20 + </div>
21 + );
22 + })}
23 + </dl>
24 + );
25 +}
added apps/web/src/components/detail/ScopeCharts.tsx +15 −0
@@ -0,0 +1,15 @@
1 +'use client';
2 +
3 +import { SeriesChart } from '@/components/charts/SeriesChart';
4 +import type { SimpleSeries } from '@/lib/types';
5 +
6 +/** 24 h pressure line for a scope with its 7-day baseline median / p90 as dashed references. */
7 +export function ScopePressureChart({ series, baseline, height = 220 }: { series: SimpleSeries; baseline?: { median: number; p90: number }; height?: number }) {
8 + const pts = series.points.map((p) => ({ ts: p.ts, value: p.pressure }));
9 + const lines = [{ name: 'Pressure', color: '#E6EDF3', points: pts, area: true, width: 1.5 }];
10 + if (baseline && pts.length) {
11 + lines.push({ name: '7d median', color: '#5B6875', points: pts.map((p) => ({ ts: p.ts, value: baseline.median })), area: false, width: 1, dashed: true } as (typeof lines)[number]);
12 + lines.push({ name: '7d p90', color: '#8B98A5', points: pts.map((p) => ({ ts: p.ts, value: baseline.p90 })), area: false, width: 1, dashed: true } as (typeof lines)[number]);
13 + }
14 + return <SeriesChart lines={lines} height={height} />;
15 +}
added apps/web/src/components/detail/ScopeHeader.tsx +43 −0
@@ -0,0 +1,43 @@
1 +import type { ReactNode } from 'react';
2 +import { ConfBar, Delta, LevelBadge } from '@/components/ui/primitives';
3 +import { fmt } from '@/lib/format';
4 +import { pressureColor } from '@/lib/pressure';
5 +import { Time } from '@/lib/time';
6 +import type { LevelId, Trend } from '@/lib/types';
7 +
8 +/** Header for a scope page (region, country, ASN, service): kicker, title, big pressure, level, Δ1h, meta. */
9 +export function ScopeHeader({ kicker, title, subtitle, pressure, level, delta1h, trend, confidence, ts, meta, right }: { kicker: ReactNode; title: string; subtitle?: ReactNode; pressure: number | null; level?: LevelId; delta1h?: number | null; trend?: Trend; confidence?: number | null; ts?: string | null; meta?: ReactNode; right?: ReactNode }) {
10 + return (
11 + <header className="grid gap-6 pt-6 pb-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
12 + <div className="min-w-0">
13 + <p className="label">{kicker}</p>
14 + <h1 className="mt-1 text-[26px] font-medium leading-tight tracking-tight text-ink sm:text-[32px]">{title}</h1>
15 + {subtitle && <p className="mt-1 text-[13px] text-ink-2">{subtitle}</p>}
16 + {meta && <div className="num mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[12px] text-ink-2">{meta}</div>}
17 + </div>
18 + <div className="flex flex-wrap items-end gap-x-6 gap-y-2 md:justify-end">
19 + <div>
20 + <div className="label">pressure</div>
21 + <div className="num text-[64px] leading-none tracking-[-0.03em]" style={{ color: pressure == null ? 'var(--ink-3)' : pressureColor(pressure) }}>
22 + {fmt(pressure)}
23 + </div>
24 + </div>
25 + <div className="flex flex-col gap-1 pb-1">
26 + {level && <LevelBadge level={level} size="md" />}
27 + {delta1h !== undefined && (
28 + <span className="text-[15px]">
29 + <Delta value={delta1h} trend={trend} suffix="/ 1h" />
30 + </span>
31 + )}
32 + {confidence != null && <ConfBar value={confidence} />}
33 + {ts && (
34 + <span className="num text-[10.5px] text-ink-3">
35 + engine <Time ts={ts} style="time" />
36 + </span>
37 + )}
38 + </div>
39 + {right}
40 + </div>
41 + </header>
42 + );
43 +}
added apps/web/src/components/detail/Tables.tsx +188 −0
@@ -0,0 +1,188 @@
1 +import Link from 'next/link';
2 +import { Empty, PNum, StatusDot } from '@/components/ui/primitives';
3 +import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';
4 +import { Time } from '@/lib/time';
5 +import type { CountryTargetRow, LatencyPair, Probe, TargetRow } from '@/lib/types';
6 +
7 +export function TargetsTable({ targets, showCategory = false }: { targets: (TargetRow | CountryTargetRow)[]; showCategory?: boolean }) {
8 + if (!targets.length) return <Empty>No target anchored in this scope.</Empty>;
9 + const sorted = [...targets].sort((a, b) => b.pressure - a.pressure);
10 + return (
11 + <div className="scroll-x -mx-3 px-3">
12 + <table className="tbl">
13 + <thead>
14 + <tr>
15 + <th>Target</th>
16 + {showCategory && <th className="hidden sm:table-cell">Category</th>}
17 + <th className="r">Pressure</th>
18 + <th className="r">OK 1h</th>
19 + <th className="r hidden sm:table-cell">TTFB p50</th>
20 + </tr>
21 + </thead>
22 + <tbody>
23 + {sorted.map((t) => (
24 + <tr key={t.target_id}>
25 + <td>
26 + <span className="text-ink">{t.name}</span> <span className="num text-[10.5px] text-ink-3">{t.target_id}</span>
27 + </td>
28 + {showCategory && <td className="hidden text-ink-2 sm:table-cell">{'category' in t ? t.category : '—'}</td>}
29 + <td className="r">
30 + <PNum value={t.pressure} />
31 + </td>
32 + <td className="num r" style={{ color: t.ok_ratio_1h < 0.98 ? 'var(--p-high)' : 'var(--ink)' }}>
33 + {fmtPct(t.ok_ratio_1h, 1)}
34 + </td>
35 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtMs(t.ttfb_ms_median)}</td>
36 + </tr>
37 + ))}
38 + </tbody>
39 + </table>
40 + </div>
41 + );
42 +}
43 +
44 +export function ProbesTable({ probes, compact = false }: { probes: Probe[]; compact?: boolean }) {
45 + if (!probes.length) return <Empty>No probe in this scope — pressure here is the destination view only.</Empty>;
46 + return (
47 + <div className="scroll-x -mx-3 px-3">
48 + <table className="tbl">
49 + <thead>
50 + <tr>
51 + <th>Probe</th>
52 + <th>Status</th>
53 + <th className="hidden md:table-cell">Location</th>
54 + <th className="hidden sm:table-cell">Provider</th>
55 + <th className="r">ASN</th>
56 + {!compact && (
57 + <>
58 + <th className="r hidden sm:table-cell">Meas./h</th>
59 + <th className="r hidden md:table-cell">Uptime 24h</th>
60 + <th className="r hidden lg:table-cell">Clock</th>
61 + <th className="hidden lg:table-cell">Version</th>
62 + <th className="hidden md:table-cell">Capabilities</th>
63 + </>
64 + )}
65 + <th className="r">Last seen</th>
66 + </tr>
67 + </thead>
68 + <tbody>
69 + {probes.map((p) => (
70 + <tr key={p.probe_id}>
71 + <td>
72 + <span className="num text-ink">{p.probe_id}</span>
73 + <span className="ml-2 hidden text-ink-2 xl:inline">{p.name}</span>
74 + </td>
75 + <td>
76 + <StatusDot status={p.status} />
77 + </td>
78 + <td className="hidden text-ink-2 md:table-cell">
79 + {p.city}, {p.country} ·{' '}
80 + <Link href={`/internet/${p.region}`} className="hover:text-accent">
81 + {p.region}
82 + </Link>
83 + </td>
84 + <td className="hidden text-ink-2 sm:table-cell">{p.provider}</td>
85 + <td className="num r">
86 + <Link href={`/asn/${p.asn}`} className="hover:text-accent">
87 + {p.asn}
88 + </Link>
89 + </td>
90 + {!compact && (
91 + <>
92 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(p.measurements_1h)}</td>
93 + <td className="num r hidden md:table-cell" style={{ color: p.uptime_24h < 0.99 ? 'var(--warn)' : 'var(--ink)' }}>
94 + {fmtPct(p.uptime_24h, 1)}
95 + </td>
96 + <td className="num r hidden text-ink-2 lg:table-cell" style={{ color: Math.abs(p.clock_offset_ms) > 50 ? 'var(--warn)' : undefined }}>
97 + {p.clock_offset_ms > 0 ? '+' : ''}
98 + {p.clock_offset_ms} ms
99 + </td>
100 + <td className="num hidden text-ink-2 lg:table-cell">{p.version}</td>
101 + <td className="hidden text-[11px] text-ink-3 md:table-cell">{p.capabilities.join(' · ')}</td>
102 + </>
103 + )}
104 + <td className="num r text-ink-2">
105 + <Time ts={p.last_seen} style="time" />
106 + </td>
107 + </tr>
108 + ))}
109 + </tbody>
110 + </table>
111 + </div>
112 + );
113 +}
114 +
115 +export function LatencyMatrixTable({ rows, highlight }: { rows: LatencyPair[]; highlight?: string }) {
116 + if (!rows.length) return <Empty>No inter-region latency pairs for this scope.</Empty>;
117 + return (
118 + <div className="scroll-x -mx-3 px-3">
119 + <table className="tbl">
120 + <thead>
121 + <tr>
122 + <th>From</th>
123 + <th>To</th>
124 + <th className="r">RTT</th>
125 + <th className="r hidden sm:table-cell">Baseline</th>
126 + <th className="r">Δ</th>
127 + <th className="r hidden sm:table-cell">TTFB</th>
128 + <th className="r">Loss</th>
129 + <th className="r">z</th>
130 + <th className="r hidden md:table-cell">Pairs</th>
131 + </tr>
132 + </thead>
133 + <tbody>
134 + {rows.map((m) => {
135 + const d = m.rtt_ms - m.rtt_ms_baseline;
136 + return (
137 + <tr key={`${m.from}-${m.to}`}>
138 + <td className={m.from === highlight ? 'text-ink' : 'text-ink-2'}>
139 + <Link href={`/internet/${m.from}`} className="hover:text-accent">
140 + {m.from}
141 + </Link>
142 + </td>
143 + <td className={m.to === highlight ? 'text-ink' : 'text-ink-2'}>
144 + <Link href={`/internet/${m.to}`} className="hover:text-accent">
145 + {m.to}
146 + </Link>
147 + </td>
148 + <td className="num r">{fmtMs(m.rtt_ms, 1)}</td>
149 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtMs(m.rtt_ms_baseline, 1)}</td>
150 + <td className="num r" style={{ color: d > 5 ? 'var(--p-stressed)' : d < -5 ? 'var(--p-calm)' : 'var(--ink-2)' }}>
151 + {d > 0 ? '+' : ''}
152 + {fmt(d, 1)}
153 + </td>
154 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtMs(m.ttfb_ms)}</td>
155 + <td className="num r" style={{ color: m.loss_pct >= 1 ? 'var(--p-high)' : 'var(--ink)' }}>
156 + {fmt(m.loss_pct)} %
157 + </td>
158 + <td className="num r" style={{ color: Math.abs(m.z) >= 3 ? 'var(--p-high)' : Math.abs(m.z) >= 1.5 ? 'var(--p-elevated)' : 'var(--ink)' }}>
159 + {fmt(m.z)}
160 + </td>
161 + <td className="num r hidden text-ink-2 md:table-cell">{fmtInt(m.pairs)}</td>
162 + </tr>
163 + );
164 + })}
165 + </tbody>
166 + </table>
167 + </div>
168 + );
169 +}
170 +
171 +export function LinkList({ items, hrefFor, label }: { items: { key: string; name: string; pressure: number; sub?: string }[]; hrefFor: (k: string) => string; label: string }) {
172 + if (!items.length) return <Empty>No {label} in this scope.</Empty>;
173 + return (
174 + <ul className="divide-y divide-line">
175 + {[...items]
176 + .sort((a, b) => b.pressure - a.pressure)
177 + .map((i) => (
178 + <li key={i.key} className="flex items-baseline justify-between gap-3 py-1.5 text-[13px]">
179 + <Link href={hrefFor(i.key)} className="truncate text-ink hover:text-accent">
180 + {i.name}
181 + {i.sub && <span className="num ml-2 text-[10.5px] text-ink-3">{i.sub}</span>}
182 + </Link>
183 + <PNum value={i.pressure} />
184 + </li>
185 + ))}
186 + </ul>
187 + );
188 +}
added apps/web/src/components/gauge/ExplainPanel.tsx +163 −0
@@ -0,0 +1,163 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { useEffect, useState } from 'react';
5 +import { Bar } from '@/components/ui/primitives';
6 +import { fmt, fmtDelta, fmtInt } from '@/lib/format';
7 +import { COMPONENT_LABEL, pressureColor } from '@/lib/pressure';
8 +import { Time } from '@/lib/time';
9 +import type { Explain, GlobalPressure } from '@/lib/types';
10 +
11 +function scopeHref(scopeType: string, scopeId: string | null): string | null {
12 + if (!scopeId) return null;
13 + if (scopeType === 'region') return `/internet/${scopeId}`;
14 + if (scopeType === 'country') return `/country/${scopeId.toLowerCase()}`;
15 + if (scopeType === 'asn') return `/asn/${scopeId}`;
16 + if (scopeType === 'service') return `/service/${scopeId}`;
17 + if (scopeType === 'bgp') return '/bgp';
18 + return null;
19 +}
20 +
21 +/** "Why is Global Pressure 42.7?" — explain rows, then components → signals from /api/v1/explain. */
22 +export function ExplainPanel({ global, onClose }: { global: GlobalPressure; onClose: () => void }) {
23 + const [deep, setDeep] = useState<Explain | null>(null);
24 + const [err, setErr] = useState<string | null>(null);
25 + const [openComp, setOpenComp] = useState<string | null>(null);
26 +
27 + useEffect(() => {
28 + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
29 + window.addEventListener('keydown', onKey);
30 + document.body.style.overflow = 'hidden';
31 + fetch('/api/v1/explain')
32 + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
33 + .then((d: Explain) => setDeep(d))
34 + .catch(() => setErr('Deep explanation unavailable'));
35 + return () => {
36 + window.removeEventListener('keydown', onKey);
37 + document.body.style.overflow = '';
38 + };
39 + }, [onClose]);
40 +
41 + return (
42 + <div className="fixed inset-0 z-[90] flex justify-end bg-bg/70" onClick={onClose} role="presentation">
43 + <aside role="dialog" aria-modal="true" aria-labelledby="explain-title" className="flex h-full w-full max-w-[640px] flex-col border-l border-line bg-panel" onClick={(e) => e.stopPropagation()}>
44 + <header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
45 + <div>
46 + <p className="label">Explain</p>
47 + <h2 id="explain-title" className="mt-1 text-[17px] font-medium">
48 + Why is Global Pressure <span className="num" style={{ color: pressureColor(global.level) }}>{fmt(global.pressure)}</span>?
49 + </h2>
50 + <p className="mt-1 text-[11.5px] text-ink-2">
51 + Engine cycle <Time ts={global.ts} style="time" className="num" /> · confidence {Math.round(global.confidence * 100)} %
52 + </p>
53 + </div>
54 + <button type="button" onClick={onClose} className="rounded-[3px] border border-line px-2 py-1 text-xs text-ink-2 hover:text-ink" aria-label="Close">
55 + esc
56 + </button>
57 + </header>
58 +
59 + <div className="flex-1 overflow-y-auto px-5 py-4">
60 + <p className="label mb-2">Contributions</p>
61 + <ul className="divide-y divide-line">
62 + {global.explain.map((row, i) => {
63 + const href = scopeHref(row.scope_type, row.scope_id);
64 + return (
65 + <li key={i} className="flex items-baseline gap-3 py-2 text-[13px]">
66 + <span className="num w-14 shrink-0 text-right" style={{ color: row.points >= 0 ? pressureColor(Math.min(100, 30 + row.points * 3)) : 'var(--p-calm)' }}>
67 + {fmtDelta(row.points)}
68 + </span>
69 + <span className="flex-1 text-ink">{row.text.replace(/^[+−-]\s?[\d.]+\s?(points?)?\s?(from|because)?\s?/i, (m) => (m.trim().endsWith('because') ? 'Because ' : ''))}</span>
70 + <span className="label shrink-0">{COMPONENT_LABEL[row.component] ?? row.component}</span>
71 + {href && (
72 + <Link href={href} className="shrink-0 text-xs text-accent hover:underline" onClick={onClose}>
73 + open →
74 + </Link>
75 + )}
76 + </li>
77 + );
78 + })}
79 + </ul>
80 +
81 + <p className="label mb-2 mt-6">Components → signals</p>
82 + {err && <p className="text-xs text-warn">{err}</p>}
83 + {!deep && !err && <p className="text-xs text-ink-3">Loading signal breakdown…</p>}
84 + {deep && (
85 + <ul className="divide-y divide-line">
86 + {deep.components.map((c) => {
87 + const isOpen = openComp === c.id;
88 + return (
89 + <li key={c.id}>
90 + <button type="button" onClick={() => setOpenComp(isOpen ? null : c.id)} className="grid w-full grid-cols-[1fr_auto_auto_auto] items-center gap-3 py-2 text-left text-[13px]" aria-expanded={isOpen}>
91 + <span className="text-ink">
92 + <span className="mr-2 inline-block w-3 text-ink-3">{isOpen ? '−' : '+'}</span>
93 + {COMPONENT_LABEL[c.id] ?? c.id}
94 + </span>
95 + <span className="num text-right" style={{ color: pressureColor(c.score) }}>
96 + {fmt(c.score)}
97 + </span>
98 + <span className="num text-right text-ink-3">× {fmt(c.weight, 2)}</span>
99 + <span className="num w-12 text-right text-ink">{fmtDelta(c.contribution)}</span>
100 + </button>
101 + {isOpen && (
102 + <table className="tbl mb-3">
103 + <thead>
104 + <tr>
105 + <th>Signal</th>
106 + <th>Scope</th>
107 + <th className="r">Current</th>
108 + <th className="r">Median</th>
109 + <th className="r">MAD</th>
110 + <th className="r">z</th>
111 + <th className="r">n</th>
112 + <th className="r">Stress</th>
113 + <th className="r">Pts</th>
114 + </tr>
115 + </thead>
116 + <tbody>
117 + {c.signals.map((s) => (
118 + <tr key={`${s.signal_id}-${s.scope_id}`}>
119 + <td className="max-w-[200px] truncate text-ink" title={s.label}>
120 + {s.label}
121 + </td>
122 + <td className="text-ink-2">{s.scope_id ? `${s.scope_type}:${s.scope_id}` : s.scope_type}</td>
123 + <td className="num r">{fmt(s.current, s.current < 1 ? 3 : 1)}</td>
124 + <td className="num r text-ink-2">{fmt(s.baseline_median, s.baseline_median < 1 ? 3 : 1)}</td>
125 + <td className="num r text-ink-2">{fmt(s.mad, s.mad < 1 ? 3 : 1)}</td>
126 + <td className="num r" style={{ color: Math.abs(s.robust_z) >= 3 ? 'var(--p-high)' : 'var(--ink)' }}>
127 + {fmt(s.robust_z)}
128 + </td>
129 + <td className="num r text-ink-2">{fmtInt(s.samples)}</td>
130 + <td className="r">
131 + <Bar value={s.stress * 100} className="inline-block w-10 align-middle" />
132 + </td>
133 + <td className="num r">{fmtDelta(s.contribution)}</td>
134 + </tr>
135 + ))}
136 + </tbody>
137 + </table>
138 + )}
139 + </li>
140 + );
141 + })}
142 + </ul>
143 + )}
144 + {deep?.notes?.length ? (
145 + <ul className="mt-5 space-y-1 text-[11.5px] text-ink-3">
146 + {deep.notes.map((n, i) => (
147 + <li key={i}>· {n}</li>
148 + ))}
149 + </ul>
150 + ) : null}
151 + {deep?.excluded_probes?.length ? <p className="mt-3 text-[11.5px] text-warn">Excluded probes (self-exclusion): {deep.excluded_probes.join(', ')}</p> : null}
152 + </div>
153 + <footer className="border-t border-line px-5 py-3 text-[11.5px] text-ink-3">
154 + Composite observability index, not scientific truth. Weights and thresholds in{' '}
155 + <Link href="/methodology" className="text-accent hover:underline" onClick={onClose}>
156 + Methodology
157 + </Link>
158 + .
159 + </footer>
160 + </aside>
161 + </div>
162 + );
163 +}
added apps/web/src/components/gauge/Gauge.tsx +106 −0
@@ -0,0 +1,106 @@
1 +'use client';
2 +
3 +import { useState } from 'react';
4 +import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
5 +import { Sparkline } from '@/components/ui/primitives';
6 +import { fmt, fmtDelta, fmtInt } from '@/lib/format';
7 +import { useDegraded, useLive } from '@/lib/live';
8 +import { levelById, pressureColor, trendArrow } from '@/lib/pressure';
9 +import { Time } from '@/lib/time';
10 +import type { GlobalPressure } from '@/lib/types';
11 +import { ExplainPanel } from './ExplainPanel';
12 +
13 +/** The instrument. SSR renders `initial`; afterwards the live slice drives it. Click → Explain (spec §44). */
14 +export function Gauge({ initial }: { initial: GlobalPressure | null }) {
15 + const live = useLive((s) => s.global);
16 + const updates = useLive((s) => s.updates);
17 + const { degraded, frozenSince } = useDegraded();
18 + const [open, setOpen] = useState(false);
19 + const g = live ?? initial;
20 +
21 + if (!g) {
22 + return (
23 + <div className="py-10">
24 + <p className="label">Global Internet Pressure</p>
25 + <p className="num mt-2 text-[96px] leading-none text-ink-3">—</p>
26 + <p className="mt-3 text-sm text-warn">The pressure API is unreachable. This is our failure, not an Internet event.</p>
27 + </div>
28 + );
29 + }
30 +
31 + const color = pressureColor(g.level);
32 + const level = levelById(g.level);
33 + const dim = degraded ? 'opacity-50 saturate-50' : '';
34 +
35 + return (
36 + <div className="relative">
37 + {/* faint radial glow tinted by the current level — the only gradient on the site */}
38 + <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 -top-10 h-[360px] max-w-[560px] opacity-[0.22]" style={{ background: `radial-gradient(ellipse 60% 55% at 30% 45%, ${color}, transparent 70%)` }} />
39 + <p className="label relative">Global Internet Pressure</p>
40 + <button
41 + type="button"
42 + onClick={() => setOpen(true)}
43 + className={`group relative mt-1 flex flex-wrap items-baseline gap-x-5 gap-y-1 text-left ${dim}`}
44 + aria-label={`Global Internet Pressure ${fmt(g.pressure)}, ${level?.label ?? g.level}. Open explanation.`}
45 + aria-haspopup="dialog"
46 + >
47 + <AnimatedNumber
48 + value={g.pressure}
49 + digits={1}
50 + duration={updates > 0 ? 700 : 0}
51 + className="text-[112px] font-semibold leading-[0.9] tracking-[-0.045em] text-ink sm:text-[160px] lg:text-[184px]"
52 + aria-live="polite"
53 + aria-atomic="true"
54 + />
55 + <span className="flex flex-col gap-1">
56 + <span className="text-[22px] font-medium uppercase tracking-[0.18em] sm:text-[28px]" style={{ color }}>
57 + {level?.short ?? g.level}
58 + </span>
59 + <span className="num text-[18px] text-ink sm:text-[22px]">
60 + {trendArrow(g.trend, g.delta_1h)} {fmtDelta(g.delta_1h)} <span className="text-ink-3">/ 1h</span>
61 + </span>
62 + </span>
63 + <span className="absolute -bottom-5 left-0 text-[10.5px] uppercase tracking-[0.12em] text-ink-3 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">Click to explain →</span>
64 + </button>
65 +
66 + <div className={`relative mt-7 grid gap-x-8 gap-y-2 text-[12.5px] text-ink-2 sm:grid-cols-[auto_1fr] ${dim}`}>
67 + <div className="flex flex-wrap gap-x-6 gap-y-1">
68 + <span>
69 + <span className="label mr-2">velocity</span>
70 + <span className="num text-ink">{fmtDelta(g.velocity_per_h)}/h</span>
71 + <span className="num text-ink-3"> · {fmtDelta(g.acceleration_per_h2)}/h²</span>
72 + </span>
73 + <span>
74 + <span className="label mr-2">Δ24h</span>
75 + <span className="num text-ink">{fmtDelta(g.delta_24h)}</span>
76 + </span>
77 + <span>
78 + <span className="label mr-2">volatility</span>
79 + <span className="num text-ink">{fmt(g.volatility_1h)}</span>
80 + </span>
81 + </div>
82 + <div className="num sm:text-right">
83 + {fmtInt(g.coverage.probes_active)}
84 + <span className="text-ink-3">/{fmtInt(g.coverage.probes_total)}</span> probes · {fmtInt(g.coverage.probe_regions)} regions · {fmtInt(g.coverage.targets)} targets · {fmtInt(g.coverage.bgp_collectors)} collectors · confidence{' '}
85 + <span className="text-ink">{Math.round(g.confidence * 100)} %</span>
86 + </div>
87 + </div>
88 +
89 + <div className={`relative mt-4 flex items-end gap-4 ${dim}`}>
90 + <Sparkline values={g.sparkline_1h} width={320} height={44} color={color} className="w-full max-w-[420px]" />
91 + <span className="label whitespace-nowrap">1 h</span>
92 + </div>
93 +
94 + {degraded && (
95 + <p className="relative mt-3 text-[12px] text-warn">
96 + Frozen since <Time ts={frozenSince ?? g.ts} className="num" /> — instrument degraded, not an Internet event.
97 + </p>
98 + )}
99 + <p className="relative mt-2 text-[11px] text-ink-3">
100 + Engine <Time ts={g.ts} style="time" className="num" /> · baseline {fmt(g.coverage.baseline_days)} d · {fmtInt(g.coverage.measurements_5m)} measurements / 5 min
101 + </p>
102 +
103 + {open && <ExplainPanel global={g} onClose={() => setOpen(false)} />}
104 + </div>
105 + );
106 +}
added apps/web/src/components/home/Clock.tsx +24 −0
@@ -0,0 +1,24 @@
1 +'use client';
2 +
3 +import { fmtInt } from '@/lib/format';
4 +import { useLive } from '@/lib/live';
5 +import type { Ticker } from '@/lib/types';
6 +
7 +/** Global Internet Clock (spec §36) — one sentence of real counters. */
8 +export function Clock({ initial }: { initial: Ticker | null }) {
9 + const live = useLive((s) => s.ticker);
10 + const t = live ?? initial;
11 + if (!t) return null;
12 + const n = (v: number, color?: string) => (
13 + <span className="num text-ink" style={color ? { color } : undefined}>
14 + {fmtInt(v)}
15 + </span>
16 + );
17 + return (
18 + <p className="text-[17px] leading-relaxed text-ink-2 sm:text-[20px]">
19 + <span className="label mr-3 align-middle">Right now</span>
20 + {n(t.measurements_per_min)} probe measurements/min · {n(t.bgp_updates_per_min)} BGP updates/min · {n(t.regions_normal)} regions normal · {n(t.regions_elevated, t.regions_elevated ? 'var(--p-elevated)' : undefined)} elevated ·{' '}
21 + {n(t.regions_severe, t.regions_severe ? 'var(--p-severe)' : undefined)} severe · {n(t.active_incidents, t.active_incidents ? 'var(--p-high)' : undefined)} active {t.active_incidents === 1 ? 'incident' : 'incidents'}
22 + </p>
23 + );
24 +}
added apps/web/src/components/home/ComponentRows.tsx +81 −0
@@ -0,0 +1,81 @@
1 +'use client';
2 +
3 +import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
4 +import { Bar, Delta } from '@/components/ui/primitives';
5 +import { fmt, fmtDelta } from '@/lib/format';
6 +import { useDegraded, useLive } from '@/lib/live';
7 +import { pressureColor } from '@/lib/pressure';
8 +import type { Component, GlobalPressure } from '@/lib/types';
9 +
10 +/** Component rows (desktop: dense table · phone: swipeable scroll-snap chips). */
11 +export function ComponentRows({ initial }: { initial: GlobalPressure | null }) {
12 + const live = useLive((s) => s.global);
13 + const updates = useLive((s) => s.updates);
14 + const { degraded } = useDegraded();
15 + const comps: Component[] = (live ?? initial)?.components ?? [];
16 + if (!comps.length) return <p className="text-xs text-ink-3">Components unavailable.</p>;
17 + const dim = degraded ? 'opacity-50 saturate-50' : '';
18 +
19 + return (
20 + <div className={dim}>
21 + {/* phone: chips */}
22 + <ul className="snap-row -mx-3 px-3 md:hidden" aria-label="Components">
23 + {comps.map((c) => (
24 + <li key={c.id} className="snap-item panel w-[150px] p-3">
25 + <div className="flex items-baseline justify-between">
26 + <span className="text-[12px] text-ink">{c.label}</span>
27 + <span className="num text-[10px] text-ink-3">w {fmt(c.weight, 2)}</span>
28 + </div>
29 + <div className="num mt-1 text-[28px] leading-none" style={{ color: pressureColor(c.score) }}>
30 + <AnimatedNumber value={c.score} duration={updates ? 600 : 0} />
31 + </div>
32 + <Bar value={c.score} className="mt-2" />
33 + <div className="mt-1.5 flex justify-between text-[11px]">
34 + <Delta value={c.delta_1h} trend={c.trend} />
35 + <span className="num text-ink-2">{fmtDelta(c.contribution)} pts</span>
36 + </div>
37 + </li>
38 + ))}
39 + </ul>
40 + {/* desktop: table */}
41 + <table className="tbl hidden w-full table-fixed md:table">
42 + <thead>
43 + <tr>
44 + <th className="w-[44%]">Component · main driver</th>
45 + <th className="r w-[13%]">Score</th>
46 + <th className="w-[15%]"></th>
47 + <th className="r w-[14%]">Δ1h</th>
48 + <th className="r w-[14%]">Contrib.</th>
49 + </tr>
50 + </thead>
51 + <tbody>
52 + {comps.map((c) => (
53 + <tr key={c.id} className="align-top">
54 + <td className="whitespace-normal">
55 + <div className="flex items-baseline gap-2">
56 + <span className="text-ink">{c.label}</span>
57 + <span className="num text-[10.5px] text-ink-3">w {fmt(c.weight, 2)} · conf {Math.round(c.confidence * 100)} %</span>
58 + </div>
59 + {c.drivers[0] && (
60 + <div className="mt-0.5 text-[11.5px] leading-snug text-ink-2" title={c.drivers.map((d) => `${fmtDelta(d.points)} ${d.label}`).join('\n')}>
61 + <span className="num text-ink">{fmtDelta(c.drivers[0].points)}</span> {c.drivers[0].label}
62 + </div>
63 + )}
64 + </td>
65 + <td className="num r text-[16px]" style={{ color: pressureColor(c.score) }}>
66 + <AnimatedNumber value={c.score} duration={updates ? 600 : 0} />
67 + </td>
68 + <td className="pt-3">
69 + <Bar value={c.score} />
70 + </td>
71 + <td className="r">
72 + <Delta value={c.delta_1h} trend={c.trend} />
73 + </td>
74 + <td className="num r">{fmtDelta(c.contribution)}</td>
75 + </tr>
76 + ))}
77 + </tbody>
78 + </table>
79 + </div>
80 + );
81 +}
added apps/web/src/components/home/Fronts.tsx +80 −0
@@ -0,0 +1,80 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { Bar, ConfBar, Empty, StatusDot } from '@/components/ui/primitives';
5 +import { fmt, fmtInt, fmtRatio } from '@/lib/format';
6 +import { useLive } from '@/lib/live';
7 +import { pressureColor } from '@/lib/pressure';
8 +import { Time } from '@/lib/time';
9 +import type { Front } from '@/lib/types';
10 +
11 +const DIR: Record<string, string> = { east: '→ E', west: '← W', north: '↑ N', south: '↓ S', northeast: '↗ NE', northwest: '↖ NW', southeast: '↘ SE', southwest: '↙ SW' };
12 +
13 +/** Active Pressure Fronts (spec §20) — the signature feature, expressed as source → destination. */
14 +export function Fronts({ initial }: { initial: Front[] | null }) {
15 + const live = useLive((s) => s.fronts);
16 + const fronts = live ?? initial ?? [];
17 + if (!fronts.length) return <Empty>No Pressure Front detected. Regions are not rising together.</Empty>;
18 + return (
19 + <ul className="divide-y divide-line">
20 + {fronts.map((f) => (
21 + <li key={f.id} className="grid gap-x-6 gap-y-2 py-3 md:grid-cols-[1fr_auto]">
22 + <div className="min-w-0">
23 + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
24 + <h3 className="text-[14px] font-medium text-ink">{f.name}</h3>
25 + <StatusDot status={f.status} />
26 + <span className="num text-[11px] text-ink-3">
27 + since <Time ts={f.since} style="time" />
28 + </span>
29 + </div>
30 + <p className="mt-1 text-[12.5px] text-ink-2">
31 + <Link href={`/internet/${f.from.region}`} className="text-ink hover:text-accent">
32 + {f.from.name}
33 + </Link>{' '}
34 + <span className="num text-ink-3">{DIR[f.direction] ?? f.direction}</span>{' '}
35 + <Link href={`/internet/${f.to.region}`} className="text-ink hover:text-accent">
36 + {f.to.name}
37 + </Link>
38 + </p>
39 + <dl className="num mt-2 grid grid-cols-3 gap-x-4 gap-y-1 text-[11.5px] text-ink-2 sm:grid-cols-6">
40 + <div>
41 + <dt className="label">latency</dt>
42 + <dd className="text-ink">+{fmt(f.observed.latency_pct, 0)} %</dd>
43 + </div>
44 + <div>
45 + <dt className="label">churn</dt>
46 + <dd className="text-ink">{fmtRatio(f.observed.churn_x)}</dd>
47 + </div>
48 + <div>
49 + <dt className="label">loss</dt>
50 + <dd className="text-ink">{fmt(f.observed.loss_pct)} %</dd>
51 + </div>
52 + <div>
53 + <dt className="label">pairs</dt>
54 + <dd className="text-ink">{fmtInt(f.observed.pairs)}</dd>
55 + </div>
56 + <div>
57 + <dt className="label">targets</dt>
58 + <dd className="text-ink">{fmtInt(f.observed.targets)}</dd>
59 + </div>
60 + <div>
61 + <dt className="label">route Δ</dt>
62 + <dd className="text-ink">{fmtInt(f.observed.route_changes)}</dd>
63 + </div>
64 + </dl>
65 + </div>
66 + <div className="flex items-center gap-6 md:flex-col md:items-end md:gap-2">
67 + <div className="text-right">
68 + <div className="label">intensity</div>
69 + <div className="num text-[26px] leading-none" style={{ color: pressureColor(f.intensity) }}>
70 + {fmt(f.intensity)}
71 + </div>
72 + <Bar value={f.intensity} className="mt-1 w-24" />
73 + </div>
74 + <ConfBar value={f.confidence} />
75 + </div>
76 + </li>
77 + ))}
78 + </ul>
79 + );
80 +}
added apps/web/src/components/home/IncidentsList.tsx +20 −0
@@ -0,0 +1,20 @@
1 +'use client';
2 +
3 +import { IncidentRow } from '@/components/incidents/IncidentRow';
4 +import { Empty } from '@/components/ui/primitives';
5 +import { useLive } from '@/lib/live';
6 +import type { Incident } from '@/lib/types';
7 +
8 +export function IncidentsList({ initial, compact = false, limit }: { initial: Incident[] | null; compact?: boolean; limit?: number }) {
9 + const live = useLive((s) => s.incidents);
10 + let list = live ?? initial ?? [];
11 + if (limit) list = list.slice(0, limit);
12 + if (!list.length) return <Empty>No active incident. Anomalies below the detection threshold are not reported.</Empty>;
13 + return (
14 + <ul className="divide-y divide-line">
15 + {list.map((inc) => (
16 + <IncidentRow key={inc.event_id} inc={inc} compact={compact} />
17 + ))}
18 + </ul>
19 + );
20 +}
added apps/web/src/components/home/ProbeStrip.tsx +34 −0
@@ -0,0 +1,34 @@
1 +import Link from 'next/link';
2 +import { StatusDot } from '@/components/ui/primitives';
3 +import { fmtInt, fmtPct } from '@/lib/format';
4 +import { Time } from '@/lib/time';
5 +import type { Probe } from '@/lib/types';
6 +
7 +/** Probe network strip — who is measuring, from where, and whether they are fresh. Server component. */
8 +export function ProbeStrip({ probes }: { probes: Probe[] | null }) {
9 + if (!probes?.length) return <p className="text-xs text-ink-3">Probe list unavailable.</p>;
10 + return (
11 + <ul className="grid grid-cols-2 gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-4 lg:grid-cols-8">
12 + {probes.map((p) => (
13 + <li key={p.probe_id} className="bg-panel px-3 py-2.5">
14 + <div className="flex items-baseline justify-between gap-2">
15 + <Link href="/probes" className="num truncate text-[12px] text-ink hover:text-accent">
16 + {p.probe_id}
17 + </Link>
18 + <StatusDot status={p.status} />
19 + </div>
20 + <div className="mt-0.5 truncate text-[11px] text-ink-2" title={p.name}>
21 + {p.city} · AS{p.asn}
22 + </div>
23 + <div className="num mt-1 flex justify-between text-[10.5px] text-ink-3">
24 + <span>{fmtInt(p.measurements_1h)}/h</span>
25 + <span>up {fmtPct(p.uptime_24h, 1)}</span>
26 + </div>
27 + <div className="num text-[10.5px] text-ink-3">
28 + seen <Time ts={p.last_seen} style="time" />
29 + </div>
30 + </li>
31 + ))}
32 + </ul>
33 + );
34 +}
added apps/web/src/components/home/RegionsTable.tsx +81 −0
@@ -0,0 +1,81 @@
1 +'use client';
2 +
3 +import Link from 'next/link';
4 +import { Bar, Delta, LevelBadge, PNum } from '@/components/ui/primitives';
5 +import { fmt, fmtInt } from '@/lib/format';
6 +import { useLive } from '@/lib/live';
7 +import { pressureColor } from '@/lib/pressure';
8 +import type { ComponentId, Region } from '@/lib/types';
9 +
10 +const COLS: ComponentId[] = ['routing', 'latency', 'dns', 'availability', 'http_tls', 'path'];
11 +const SHORT: Record<string, string> = { routing: 'Rout', latency: 'Lat', dns: 'DNS', availability: 'Avail', http_tls: 'HTTP', path: 'Path' };
12 +
13 +export function RegionsTable({ initial }: { initial: Region[] | null }) {
14 + const live = useLive((s) => s.regions);
15 + const regions = [...(live ?? initial ?? [])].sort((a, b) => b.pressure - a.pressure);
16 + if (!regions.length) return <p className="text-xs text-ink-3">Regions unavailable.</p>;
17 + return (
18 + <div className="scroll-x -mx-3 px-3">
19 + <table className="tbl">
20 + <thead>
21 + <tr>
22 + <th>Region</th>
23 + <th className="r">Pressure</th>
24 + <th className="hidden w-[12%] md:table-cell"></th>
25 + <th className="r">Δ1h</th>
26 + <th className="hidden lg:table-cell">Level</th>
27 + {COLS.map((c) => (
28 + <th key={c} className="r hidden md:table-cell">
29 + {SHORT[c]}
30 + </th>
31 + ))}
32 + <th className="r hidden sm:table-cell">Probes</th>
33 + <th className="r hidden sm:table-cell">Targets</th>
34 + <th className="r hidden lg:table-cell">Inc.</th>
35 + <th className="hidden lg:table-cell">Coverage</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + {regions.map((r) => (
40 + <tr key={r.id}>
41 + <td>
42 + <Link href={`/internet/${r.id}`} className="text-ink hover:text-accent">
43 + {r.name}
44 + </Link>
45 + <span className="ml-2 hidden text-[10.5px] text-ink-3 xl:inline">{r.continent}</span>
46 + </td>
47 + <td className="r">
48 + <PNum value={r.pressure} className="text-[15px]" />
49 + </td>
50 + <td className="hidden md:table-cell">
51 + <Bar value={r.pressure} />
52 + </td>
53 + <td className="r">
54 + <Delta value={r.delta_1h} trend={r.trend} />
55 + </td>
56 + <td className="hidden lg:table-cell">
57 + <LevelBadge level={r.level} size="xs" />
58 + </td>
59 + {COLS.map((c) => {
60 + const v = r.components[c];
61 + return (
62 + <td key={c} className="num r hidden md:table-cell" style={{ color: v == null ? 'var(--ink-3)' : pressureColor(v) }} title={v == null ? 'No ASN attribution for this region' : undefined}>
63 + {v == null ? '·' : fmt(v, 0)}
64 + </td>
65 + );
66 + })}
67 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(r.probes)}</td>
68 + <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(r.targets)}</td>
69 + <td className="num r hidden sm:table-cell lg:table-cell" style={{ color: r.incidents ? 'var(--p-high)' : 'var(--ink-3)' }}>
70 + {fmtInt(r.incidents)}
71 + </td>
72 + <td className="hidden text-[11px] lg:table-cell" style={{ color: r.coverage_ok ? 'var(--ink-2)' : 'var(--warn)' }}>
73 + {r.coverage_ok ? r.role : 'weak'} · {Math.round(r.confidence * 100)} %
74 + </td>
75 + </tr>
76 + ))}
77 + </tbody>
78 + </table>
79 + </div>
80 + );
81 +}
added apps/web/src/components/home/Ticker.tsx +61 −0
@@ -0,0 +1,61 @@
1 +'use client';
2 +
3 +import { AnimatedNumber } from '@/components/ui/AnimatedNumber';
4 +import { fmt } from '@/lib/format';
5 +import { useDegraded, useLive } from '@/lib/live';
6 +import { Time } from '@/lib/time';
7 +import type { Ticker as TickerT } from '@/lib/types';
8 +
9 +interface Cell {
10 + key: keyof TickerT;
11 + label: string;
12 + digits?: number;
13 + unit?: string;
14 + color?: (t: TickerT) => string | undefined;
15 +}
16 +const CELLS: Cell[] = [
17 + { key: 'bgp_updates_per_s', label: 'BGP updates/s', digits: 1 },
18 + { key: 'bgp_withdrawals_per_s', label: 'withdrawals/s', digits: 1, color: (t) => (t.bgp_withdrawals_per_s > 30 ? 'var(--p-stressed)' : undefined) },
19 + { key: 'probes_active', label: 'probes active', digits: 0, color: (t) => (t.probes_active < t.probes_total ? 'var(--warn)' : undefined) },
20 + { key: 'measurements_per_s', label: 'measurements/s', digits: 1 },
21 + { key: 'targets_degraded', label: 'targets degraded', digits: 0, color: (t) => (t.targets_degraded > 0 ? 'var(--p-elevated)' : undefined) },
22 + { key: 'regions_elevated', label: 'regions elevated', digits: 0, color: (t) => (t.regions_elevated > 0 ? 'var(--p-elevated)' : undefined) },
23 + { key: 'regions_severe', label: 'regions severe', digits: 0, color: (t) => (t.regions_severe > 0 ? 'var(--p-severe)' : undefined) },
24 + { key: 'dns_failures_per_min', label: 'DNS failures/min', digits: 0 },
25 + { key: 'median_global_rtt_ms', label: 'median RTT', digits: 1, unit: 'ms' },
26 + { key: 'route_changes_per_min', label: 'route changes/min', digits: 1 },
27 + { key: 'active_incidents', label: 'active incidents', digits: 0, color: (t) => (t.active_incidents > 0 ? 'var(--p-high)' : undefined) },
28 + { key: 'bgp_updates_per_min', label: 'BGP updates/min', digits: 0 },
29 +];
30 +
31 +/** Live ticker — a dense grid that updates in place (no marquee). Every value is a real counter (spec §66). */
32 +export function Ticker({ initial }: { initial: TickerT | null }) {
33 + const live = useLive((s) => s.ticker);
34 + const updates = useLive((s) => s.updates);
35 + const { degraded } = useDegraded();
36 + const t = live ?? initial;
37 + if (!t) return <p className="text-xs text-ink-3">Ticker unavailable.</p>;
38 + return (
39 + <div className={degraded ? 'opacity-50 saturate-50' : ''}>
40 + <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-[4px] border border-line bg-line sm:grid-cols-3 lg:grid-cols-6">
41 + {CELLS.map((c) => {
42 + const v = t[c.key] as number;
43 + return (
44 + <div key={c.key} className="bg-panel px-3 py-2.5">
45 + <dt className="label truncate">{c.label}</dt>
46 + <dd className="num mt-0.5 text-[20px] leading-none" style={{ color: c.color?.(t) ?? 'var(--ink)' }}>
47 + <AnimatedNumber value={v} digits={c.digits ?? 1} duration={updates ? 500 : 0} />
48 + {c.unit && <span className="ml-1 text-[11px] text-ink-3">{c.unit}</span>}
49 + {c.key === 'probes_active' && <span className="text-[12px] text-ink-3">/{fmt(t.probes_total, 0)}</span>}
50 + {c.key === 'targets_degraded' && <span className="text-[12px] text-ink-3">/{fmt(t.targets_total, 0)}</span>}
51 + </dd>
52 + </div>
53 + );
54 + })}
55 + </dl>
56 + <p className="num mt-1.5 text-[10.5px] text-ink-3">
57 + counters at <Time ts={t.ts} style="time" /> · {fmt(t.regions_normal, 0)} regions normal
58 + </p>
59 + </div>
60 + );
61 +}
added apps/web/src/components/incidents/IncidentRow.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import Link from 'next/link';
2 +import { ConfBar, StatusDot } from '@/components/ui/primitives';
3 +import { fmt, fmtDuration, fmtInt } from '@/lib/format';
4 +import { pressureColor } from '@/lib/pressure';
5 +import { Time } from '@/lib/time';
6 +import type { Incident } from '@/lib/types';
7 +
8 +export const TYPE_LABEL: Record<string, string> = {
9 + regional_latency: 'Latency anomaly',
10 + dns_disruption: 'DNS disruption',
11 + routing_instability: 'Routing instability',
12 + service_degradation: 'Service degradation',
13 + availability_loss: 'Availability loss',
14 + path_instability: 'Path instability',
15 + global_pressure: 'Global pressure',
16 +};
17 +
18 +export function IncidentRow({ inc, compact = false }: { inc: Incident; compact?: boolean }) {
19 + return (
20 + <li className="grid gap-x-5 gap-y-1.5 py-3 md:grid-cols-[auto_1fr_auto]">
21 + <div className="num flex items-baseline gap-3 md:w-20 md:flex-col md:items-end md:gap-0">
22 + <span className="text-[24px] leading-none" style={{ color: pressureColor(inc.current_pressure) }}>
23 + {fmt(inc.current_pressure)}
24 + </span>
25 + <span className="text-[10.5px] text-ink-3">peak {fmt(inc.peak_pressure)}</span>
26 + </div>
27 + <div className="min-w-0">
28 + <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
29 + <Link href={`/event/${inc.slug}`} className="text-[14px] font-medium text-ink hover:text-accent">
30 + {inc.title}
31 + </Link>
32 + <StatusDot status={inc.status} />
33 + <span className="label">{TYPE_LABEL[inc.type] ?? inc.type}</span>
34 + </div>
35 + {!compact && <p className="mt-1 text-[12.5px] text-ink-2">{inc.summary}</p>}
36 + <p className="num mt-1 text-[11px] text-ink-3">
37 + {inc.scope_label} · started <Time ts={inc.started_at} /> · {fmtDuration(inc.duration_s)} · {fmtInt(inc.affected_probes)} probes · {fmtInt(inc.affected_targets)} targets
38 + {inc.affected_asns.length ? ` · AS${inc.affected_asns.slice(0, 3).join(', AS')}` : ''}
39 + </p>
40 + </div>
41 + <div className="md:text-right">
42 + <ConfBar value={inc.confidence} />
43 + </div>
44 + </li>
45 + );
46 +}
added apps/web/src/components/incidents/IncidentsSection.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { Empty } from '@/components/ui/primitives';
2 +import type { Incident } from '@/lib/types';
3 +import { IncidentRow } from './IncidentRow';
4 +
5 +export function IncidentsSection({ incidents, compact = true }: { incidents: Incident[]; compact?: boolean }) {
6 + if (!incidents.length) return <Empty>No incident recorded for this scope.</Empty>;
7 + return (
8 + <ul className="divide-y divide-line">
9 + {incidents.map((i) => (
10 + <IncidentRow key={i.event_id} inc={i} compact={compact} />
11 + ))}
12 + </ul>
13 + );
14 +}
added apps/web/src/components/map/MapIsland.tsx +56 −0
@@ -0,0 +1,56 @@
1 +'use client';
2 +
3 +import dynamic from 'next/dynamic';
4 +import { useMemo, useState } from 'react';
5 +import { LevelLegend } from '@/components/ui/primitives';
6 +import { useLive } from '@/lib/live';
7 +import type { Country, Front, Incident, LatencyPair, Probe, Region } from '@/lib/types';
8 +import { ModeSelector } from './ModeSelector';
9 +import { type MapMode } from './modes';
10 +
11 +const WorldMap = dynamic(() => import('./WorldMap').then((m) => m.WorldMap), {
12 + ssr: false,
13 + loading: () => <div className="flex h-full w-full items-center justify-center bg-neutral text-[11px] text-ink-3">Loading map…</div>,
14 +});
15 +
16 +export interface MapData {
17 + regions: Region[] | null;
18 + countries: Country[] | null;
19 + probes: Probe[] | null;
20 + fronts: Front[] | null;
21 + incidents: Incident[] | null;
22 + matrix: LatencyPair[] | null;
23 +}
24 +
25 +/** Client island: the map itself is lazy-loaded (no SSR); live slices override the SSR data. */
26 +export function MapIsland({ initial, height = 'h-[300px] sm:h-[420px] lg:h-[520px]', initialMode = 'pressure' }: { initial: MapData; height?: string; initialMode?: MapMode }) {
27 + const [mode, setMode] = useState<MapMode>(initialMode);
28 + const liveRegions = useLive((s) => s.regions);
29 + const liveFronts = useLive((s) => s.fronts);
30 + const liveIncidents = useLive((s) => s.incidents);
31 + const liveCountries = useLive((s) => s.countries);
32 +
33 + // Memoised so the map's data effect only re-runs when a real regional update arrived.
34 + const countries = useMemo(() => {
35 + if (!initial.countries) return null;
36 + if (!liveCountries) return initial.countries;
37 + const byCc = new Map(liveCountries.map((x) => [x.cc, x]));
38 + return initial.countries.map((c) => {
39 + const u = byCc.get(c.cc);
40 + return u ? { ...c, pressure: u.pressure, level: u.level, delta_1h: u.delta_1h } : c;
41 + });
42 + }, [initial.countries, liveCountries]);
43 +
44 + return (
45 + <div>
46 + <ModeSelector mode={mode} onChange={setMode} />
47 + <div className={`relative mt-2 w-full overflow-hidden rounded-[4px] border border-line bg-neutral ${height}`}>
48 + <WorldMap mode={mode} regions={liveRegions ?? initial.regions} countries={countries} probes={initial.probes} fronts={liveFronts ?? initial.fronts} incidents={liveIncidents ?? initial.incidents} matrix={initial.matrix} />
49 + </div>
50 + <div className="mt-2 flex flex-wrap items-center justify-between gap-2">
51 + <LevelLegend compact />
52 + <span className="text-[10.5px] text-ink-3">{mode === 'probes' ? 'Arc thickness = |z| of the inter-region latency matrix' : mode === 'incidents' ? 'Markers = incidents with a geographic scope' : 'Countries coloured only where we hold measurements'}</span>
53 + </div>
54 + </div>
55 + );
56 +}
added apps/web/src/components/map/ModeSelector.tsx +38 −0
@@ -0,0 +1,38 @@
1 +'use client';
2 +
3 +import { useRef } from 'react';
4 +import { MODES, type MapMode } from './modes';
5 +
6 +/** Keyboard-navigable radiogroup (←/→ move, space/enter select). */
7 +export function ModeSelector({ mode, onChange }: { mode: MapMode; onChange: (m: MapMode) => void }) {
8 + const ref = useRef<HTMLDivElement>(null);
9 + const onKey = (e: React.KeyboardEvent, i: number) => {
10 + let next = i;
11 + if (e.key === 'ArrowRight') next = (i + 1) % MODES.length;
12 + else if (e.key === 'ArrowLeft') next = (i - 1 + MODES.length) % MODES.length;
13 + else if (e.key === 'Home') next = 0;
14 + else if (e.key === 'End') next = MODES.length - 1;
15 + else return;
16 + e.preventDefault();
17 + onChange(MODES[next]!.id);
18 + (ref.current?.children[next] as HTMLElement | undefined)?.focus();
19 + };
20 + return (
21 + <div ref={ref} role="radiogroup" aria-label="Map mode" className="scroll-x -mx-3 flex gap-1 px-3 pb-1 sm:mx-0 sm:flex-wrap sm:px-0">
22 + {MODES.map((m, i) => (
23 + <button
24 + key={m.id}
25 + type="button"
26 + role="radio"
27 + aria-checked={mode === m.id}
28 + tabIndex={mode === m.id ? 0 : -1}
29 + onClick={() => onChange(m.id)}
30 + onKeyDown={(e) => onKey(e, i)}
31 + className={`whitespace-nowrap rounded-[3px] border px-2 py-1 text-[11px] tracking-[0.04em] ${mode === m.id ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}
32 + >
33 + {m.label}
34 + </button>
35 + ))}
36 + </div>
37 + );
38 +}
added apps/web/src/components/map/WorldMap.tsx +327 −0
@@ -0,0 +1,327 @@
1 +'use client';
2 +
3 +import * as maplibregl from 'maplibre-gl';
4 +import type { ExpressionSpecification, GeoJSONSource, StyleSpecification } from 'maplibre-gl';
5 +import 'maplibre-gl/dist/maplibre-gl.css';
6 +import { useRouter } from 'next/navigation';
7 +import { useEffect, useMemo, useRef, useState } from 'react';
8 +import { feature } from 'topojson-client';
9 +import type { Topology, GeometryCollection } from 'topojson-specification';
10 +import countries110 from 'world-atlas/countries-110m.json';
11 +import { fmt, fmtDelta } from '@/lib/format';
12 +import { greatCircle } from '@/lib/geo';
13 +import { numericToAlpha2 } from '@/lib/iso-numeric-to-alpha2';
14 +import { NEUTRAL, levelWord, pressureColor } from '@/lib/pressure';
15 +import type { Country, Front, Incident, LatencyPair, LevelId, Probe, Region } from '@/lib/types';
16 +import { MODES, lossToScale, type MapMode } from './modes';
17 +
18 +const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';
19 +/** Offline/blocked fallback: our own countries layer on a near-black plane — never a fake basemap. */
20 +const FALLBACK_STYLE: StyleSpecification = { version: 8, name: 'ip-fallback', glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#070A0F' } }] };
21 +const DASH_FRAMES: number[][] = [
22 + [0, 4, 3],
23 + [0.5, 4, 2.5],
24 + [1, 4, 2],
25 + [1.5, 4, 1.5],
26 + [2, 4, 1],
27 + [2.5, 4, 0.5],
28 + [3, 4, 0],
29 + [0, 0.5, 3, 3.5],
30 + [0, 1, 3, 3],
31 + [0, 1.5, 3, 2.5],
32 + [0, 2, 3, 2],
33 + [0, 2.5, 3, 1.5],
34 + [0, 3, 3, 1],
35 + [0, 3.5, 3, 0.5],
36 +];
37 +
38 +type FC = GeoJSON.FeatureCollection;
39 +const EMPTY_FC: FC = { type: 'FeatureCollection', features: [] };
40 +
41 +interface Props {
42 + mode: MapMode;
43 + regions: Region[] | null;
44 + countries: Country[] | null;
45 + probes: Probe[] | null;
46 + fronts: Front[] | null;
47 + incidents: Incident[] | null;
48 + matrix: LatencyPair[] | null;
49 +}
50 +
51 +function scopeValue(mode: MapMode, components: Record<string, number | null | undefined> | undefined, pressure: number, loss?: number | null): number | null {
52 + const def = MODES.find((m) => m.id === mode);
53 + if (mode === 'pressure' || mode === 'incidents' || mode === 'probes') return pressure;
54 + if (mode === 'loss') return lossToScale(loss);
55 + if (def?.component) return components?.[def.component] ?? null;
56 + return pressure;
57 +}
58 +
59 +export function WorldMap({ mode, regions, countries, probes, fronts, incidents, matrix }: Props) {
60 + const el = useRef<HTMLDivElement>(null);
61 + const mapRef = useRef<maplibregl.Map | null>(null);
62 + const [ready, setReady] = useState(false);
63 + const [tip, setTip] = useState<{ x: number; y: number; html: string } | null>(null);
64 + const router = useRouter();
65 +
66 + // Base countries GeoJSON (computed once; ~180 features).
67 + const baseCountries = useMemo<FC>(() => {
68 + const topo = countries110 as unknown as Topology<{ countries: GeometryCollection }>;
69 + const fc = feature(topo, topo.objects.countries) as unknown as FC;
70 + for (const f of fc.features) {
71 + const cc = numericToAlpha2(f.id as string);
72 + f.properties = { ...(f.properties ?? {}), cc: cc ?? null };
73 + }
74 + return fc;
75 + }, []);
76 +
77 + // Per-region loss from the latency matrix (source view) — used by the Packet-loss mode.
78 + const lossByRegion = useMemo(() => {
79 + const m = new Map<string, { sum: number; n: number }>();
80 + for (const p of matrix ?? []) {
81 + const e = m.get(p.from) ?? { sum: 0, n: 0 };
82 + e.sum += p.loss_pct;
83 + e.n++;
84 + m.set(p.from, e);
85 + }
86 + return new Map([...m].map(([k, v]) => [k, v.sum / v.n]));
87 + }, [matrix]);
88 +
89 + // ---- init map once
90 + useEffect(() => {
91 + if (!el.current || mapRef.current) return;
92 + // MapLibre 6 module worker: served from /public (scripts/copy-maplibre-worker.mjs) — the bundler cannot resolve it.
93 + maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs');
94 + const map = new maplibregl.Map({
95 + container: el.current,
96 + style: STYLE_URL,
97 + center: [12, 22],
98 + zoom: 1.15,
99 + minZoom: 0.7,
100 + maxZoom: 6,
101 + renderWorldCopies: false,
102 + attributionControl: { compact: true },
103 + dragRotate: false,
104 + pitchWithRotate: false,
105 + touchPitch: false,
106 + canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
107 + });
108 + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
109 + if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipMap?: maplibregl.Map }).__ipMap = map;
110 + let fellBack = false;
111 + map.on('error', (e) => {
112 + // Style/tiles unreachable (offline, blocked): fall back to our own layers only.
113 + const msg = String((e as { error?: { message?: string } }).error?.message ?? '');
114 + if (!fellBack && !map.isStyleLoaded() && /style|fetch|Failed|NetworkError|403|404|5\d\d/i.test(msg)) {
115 + fellBack = true;
116 + map.setStyle(FALLBACK_STYLE);
117 + }
118 + });
119 + const onStyle = () => {
120 + if (map.getSource('countries')) return;
121 + // dim basemap labels/roads: keep only water/land/boundaries/country labels from the vendor style
122 + for (const layer of map.getStyle().layers ?? []) {
123 + if (/road|transit|building|poi|housenumber|aeroway|rail|ferry|path|place_(?!country|continent)|water_name|waterway/.test(layer.id)) {
124 + try {
125 + map.setLayoutProperty(layer.id, 'visibility', 'none');
126 + } catch {
127 + /* ignore */
128 + }
129 + }
130 + }
131 + map.addSource('countries', { type: 'geojson', data: EMPTY_FC });
132 + map.addSource('regions', { type: 'geojson', data: EMPTY_FC });
133 + map.addSource('probes', { type: 'geojson', data: EMPTY_FC });
134 + map.addSource('fronts', { type: 'geojson', data: EMPTY_FC });
135 + map.addSource('arcs', { type: 'geojson', data: EMPTY_FC });
136 + map.addSource('incidents', { type: 'geojson', data: EMPTY_FC });
137 +
138 + const firstSymbol = (map.getStyle().layers ?? []).find((l) => l.type === 'symbol')?.id;
139 + map.addLayer({ id: 'countries-fill', type: 'fill', source: 'countries', paint: { 'fill-color': ['get', 'color'] as ExpressionSpecification, 'fill-opacity': ['case', ['boolean', ['get', 'observed'], false], 0.42, 0.9] as ExpressionSpecification } }, firstSymbol);
140 + map.addLayer({ id: 'countries-line', type: 'line', source: 'countries', paint: { 'line-color': '#1B2430', 'line-width': 0.5 } }, firstSymbol);
141 + map.addLayer({ id: 'arcs', type: 'line', source: 'arcs', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['get', 'width'] as ExpressionSpecification, 'line-opacity': 0.55 } });
142 + map.addLayer({ id: 'fronts-glow', type: 'line', source: 'fronts', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['+', ['get', 'width'], 6] as ExpressionSpecification, 'line-opacity': 0.12, 'line-blur': 4 } });
143 + map.addLayer({ id: 'fronts', type: 'line', source: 'fronts', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['get', 'width'] as ExpressionSpecification, 'line-opacity': 0.9, 'line-dasharray': [0, 4, 3] } });
144 + map.addLayer({ id: 'fronts-arrow', type: 'symbol', source: 'fronts', layout: { 'symbol-placement': 'line', 'symbol-spacing': 140, 'text-field': '›', 'text-size': 16, 'text-font': ['Noto Sans Regular'], 'text-keep-upright': false, 'text-allow-overlap': true }, paint: { 'text-color': ['get', 'color'] as ExpressionSpecification } });
145 + map.addLayer({ id: 'incidents-ring', type: 'circle', source: 'incidents', paint: { 'circle-radius': ['get', 'radius'] as ExpressionSpecification, 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': ['get', 'color'] as ExpressionSpecification, 'circle-stroke-width': 1.5, 'circle-stroke-opacity': 0.9 } });
146 + map.addLayer({ id: 'incidents-dot', type: 'circle', source: 'incidents', paint: { 'circle-radius': 3, 'circle-color': ['get', 'color'] as ExpressionSpecification } });
147 + map.addLayer({ id: 'regions-dot', type: 'circle', source: 'regions', paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 0.7, 9, 4, 16] as ExpressionSpecification, 'circle-color': ['get', 'color'] as ExpressionSpecification, 'circle-opacity': 0.95, 'circle-stroke-color': '#070A0F', 'circle-stroke-width': 1.5 } });
148 + map.addLayer({ id: 'regions-label', type: 'symbol', source: 'regions', layout: { 'text-field': ['get', 'label'] as ExpressionSpecification, 'text-size': 10, 'text-font': ['Noto Sans Bold'], 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#070A0F' } });
149 + map.addLayer({ id: 'regions-name', type: 'symbol', source: 'regions', minzoom: 2, layout: { 'text-field': ['get', 'name'] as ExpressionSpecification, 'text-size': 10.5, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Noto Sans Regular'] }, paint: { 'text-color': '#8B98A5', 'text-halo-color': '#070A0F', 'text-halo-width': 1 } });
150 + map.addLayer({ id: 'probes', type: 'circle', source: 'probes', paint: { 'circle-radius': ['get', 'radius'] as ExpressionSpecification, 'circle-color': ['get', 'color'] as ExpressionSpecification, 'circle-opacity': 0.9, 'circle-stroke-color': '#070A0F', 'circle-stroke-width': 1 } });
151 + setReady(true);
152 + };
153 + map.on('style.load', onStyle);
154 + mapRef.current = map;
155 + return () => {
156 + map.remove();
157 + mapRef.current = null;
158 + setReady(false);
159 + };
160 + }, []);
161 +
162 + // ---- data → sources (recomputed only when data/mode change)
163 + useEffect(() => {
164 + const map = mapRef.current;
165 + if (!map || !ready) return;
166 + const isProbes = mode === 'probes';
167 + const isIncidents = mode === 'incidents';
168 + const byCc = new Map((countries ?? []).map((c) => [c.cc, c]));
169 + const countriesFc: FC = {
170 + type: 'FeatureCollection',
171 + features: baseCountries.features.map((f) => {
172 + const cc = f.properties?.cc as string | null;
173 + const c = cc ? byCc.get(cc) : undefined;
174 + const v = c ? scopeValue(mode, c.components as Record<string, number | null>, c.pressure, lossByRegion.get(c.region) ?? null) : null;
175 + const observed = Boolean(c) && v != null && !isProbes && !isIncidents;
176 + return { ...f, properties: { ...f.properties, observed, color: observed ? pressureColor(v!) : NEUTRAL, value: v, pressure: c?.pressure ?? null, level: c?.level ?? null, delta: c?.delta_1h ?? null, name: c?.name ?? null, probes: c?.probes ?? 0, targets: c?.targets ?? 0 } };
177 + }),
178 + };
179 + (map.getSource('countries') as GeoJSONSource | undefined)?.setData(countriesFc);
180 +
181 + const regionsFc: FC = {
182 + type: 'FeatureCollection',
183 + features: (regions ?? [])
184 + .filter((r) => r.id !== 'global' && !isProbes && !isIncidents)
185 + .map((r) => {
186 + const v = scopeValue(mode, r.components as Record<string, number | null>, r.pressure, lossByRegion.get(r.id) ?? null);
187 + return { type: 'Feature', geometry: { type: 'Point', coordinates: [r.lon, r.lat] }, properties: { id: r.id, name: r.name, label: v == null ? '·' : fmt(v, 0), color: v == null ? '#3A4756' : pressureColor(v), value: v, pressure: r.pressure, delta: r.delta_1h, level: r.level, probes: r.probes, targets: r.targets, incidents: r.incidents } };
188 + }),
189 + };
190 + (map.getSource('regions') as GeoJSONSource | undefined)?.setData(regionsFc);
191 +
192 + const probesFc: FC = {
193 + type: 'FeatureCollection',
194 + features: (probes ?? []).map((p) => ({
195 + type: 'Feature',
196 + geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
197 + properties: { id: p.probe_id, name: p.name, status: p.status, radius: isProbes ? 4 + Math.min(6, p.measurements_1h / 1200) : 3.5, color: p.status === 'online' ? '#7FB77E' : p.status === 'stale' ? '#E9C46A' : p.status === 'offline' ? '#D62828' : '#8B98A5', provider: p.provider, asn: p.asn },
198 + })),
199 + };
200 + (map.getSource('probes') as GeoJSONSource | undefined)?.setData(probesFc);
201 +
202 + const frontsFc: FC = {
203 + type: 'FeatureCollection',
204 + features: isProbes || isIncidents
205 + ? []
206 + : (fronts ?? []).map((f) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: greatCircle([f.from.lon, f.from.lat], [f.to.lon, f.to.lat], 64) }, properties: { id: f.id, name: f.name, status: f.status, color: pressureColor(f.intensity), width: 1.5 + f.confidence * 2, intensity: f.intensity } })),
207 + };
208 + (map.getSource('fronts') as GeoJSONSource | undefined)?.setData(frontsFc);
209 +
210 + // Probe-network mode: inter-region latency matrix as arcs (thickness = |z|)
211 + const centroid = new Map((regions ?? []).map((r) => [r.id, [r.lon, r.lat] as [number, number]]));
212 + const arcsFc: FC = {
213 + type: 'FeatureCollection',
214 + features: isProbes
215 + ? (matrix ?? [])
216 + .filter((m) => m.from !== m.to && centroid.has(m.from) && centroid.has(m.to))
217 + .map((m) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: greatCircle(centroid.get(m.from)!, centroid.get(m.to)!, 48) }, properties: { color: m.z >= 3 ? '#E76F51' : m.z >= 1.5 ? '#E9C46A' : '#3A4756', width: 0.6 + Math.min(5, Math.abs(m.z)) * 0.8, from: m.from, to: m.to, rtt: m.rtt_ms, z: m.z } }))
218 + : [],
219 + };
220 + (map.getSource('arcs') as GeoJSONSource | undefined)?.setData(arcsFc);
221 +
222 + const incFc: FC = {
223 + type: 'FeatureCollection',
224 + features: isIncidents
225 + ? (incidents ?? [])
226 + .map((i) => {
227 + const c = i.scope_type === 'region' ? centroid.get(i.scope_id ?? '') : i.scope_type === 'country' ? (byCc.get((i.scope_id ?? '').toUpperCase()) ? [byCc.get((i.scope_id ?? '').toUpperCase())!.lon, byCc.get((i.scope_id ?? '').toUpperCase())!.lat] : undefined) : undefined;
228 + if (!c) return null;
229 + return { type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: c }, properties: { slug: i.slug, title: i.title, status: i.status, color: pressureColor(i.current_pressure), radius: 8 + Math.min(20, i.affected_targets / 4), pressure: i.current_pressure } };
230 + })
231 + .filter((f): f is NonNullable<typeof f> => f != null)
232 + : [],
233 + };
234 + (map.getSource('incidents') as GeoJSONSource | undefined)?.setData(incFc);
235 + }, [ready, mode, regions, countries, probes, fronts, incidents, matrix, baseCountries, lossByRegion]);
236 +
237 + // ---- animated dash offset ONLY while a front is developing/active (real state, not decoration)
238 + useEffect(() => {
239 + const map = mapRef.current;
240 + if (!map || !ready) return;
241 + const moving = mode !== 'probes' && mode !== 'incidents' && (fronts ?? []).some((f) => f.status === 'developing' || f.status === 'active');
242 + if (!moving || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
243 + try {
244 + map.setPaintProperty('fronts', 'line-dasharray', [0, 4, 3]);
245 + } catch {
246 + /* layer missing */
247 + }
248 + return;
249 + }
250 + let step = 0;
251 + let raf = 0;
252 + let last = 0;
253 + const tick = (t: number) => {
254 + if (t - last > 70) {
255 + last = t;
256 + step = (step + 1) % DASH_FRAMES.length;
257 + try {
258 + map.setPaintProperty('fronts', 'line-dasharray', DASH_FRAMES[step]!);
259 + } catch {
260 + /* layer missing */
261 + }
262 + }
263 + raf = requestAnimationFrame(tick);
264 + };
265 + raf = requestAnimationFrame(tick);
266 + return () => cancelAnimationFrame(raf);
267 + }, [ready, fronts, mode]);
268 +
269 + // ---- interactions
270 + useEffect(() => {
271 + const map = mapRef.current;
272 + if (!map || !ready) return;
273 + const layers = ['regions-dot', 'probes', 'incidents-dot', 'incidents-ring', 'countries-fill', 'arcs'];
274 + const onMove = (e: maplibregl.MapMouseEvent) => {
275 + const feats = map.queryRenderedFeatures(e.point, { layers: layers.filter((l) => map.getLayer(l)) });
276 + const f = feats[0];
277 + if (!f) {
278 + setTip(null);
279 + map.getCanvas().style.cursor = '';
280 + return;
281 + }
282 + const p = f.properties as Record<string, unknown>;
283 + let html = '';
284 + if (f.layer.id === 'regions-dot') html = `<b>${p.name}</b><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 · ${p.incidents} incidents${mode !== 'pressure' ? `<br>${MODES.find((m) => m.id === mode)?.label}: <b>${p.value == null ? 'not observed' : fmt(p.value as number)}</b>` : ''}`;
285 + else if (f.layer.id === 'probes') html = `<b>${p.id}</b> · ${p.status}<br>${p.name}<br>${p.provider} · AS${p.asn}`;
286 + else if (f.layer.id.startsWith('incidents')) html = `<b>${p.title}</b><br>${p.status} · pressure ${fmt(p.pressure as number)}`;
287 + else if (f.layer.id === 'arcs') html = `<b>${p.from} → ${p.to}</b><br>RTT ${fmt(p.rtt as number)} ms · z ${fmt(p.z as number)}`;
288 + else if (f.layer.id === 'countries-fill') {
289 + if (!p.name) {
290 + setTip(null);
291 + map.getCanvas().style.cursor = '';
292 + return;
293 + }
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 + }
296 + map.getCanvas().style.cursor = 'pointer';
297 + setTip({ x: e.point.x, y: e.point.y, html });
298 + };
299 + const onLeave = () => setTip(null);
300 + const onClick = (e: maplibregl.MapMouseEvent) => {
301 + const feats = map.queryRenderedFeatures(e.point, { layers: ['regions-dot', 'incidents-dot', 'incidents-ring', 'countries-fill'].filter((l) => map.getLayer(l)) });
302 + const f = feats[0];
303 + if (!f) return;
304 + const p = f.properties as Record<string, unknown>;
305 + if (f.layer.id === 'regions-dot') router.push(`/internet/${p.id}`);
306 + else if (f.layer.id.startsWith('incidents')) router.push(`/event/${p.slug}`);
307 + else if (f.layer.id === 'countries-fill' && p.name && p.cc) router.push(`/country/${String(p.cc).toLowerCase()}`);
308 + };
309 + map.on('mousemove', onMove);
310 + map.on('mouseout', onLeave);
311 + map.on('click', onClick);
312 + return () => {
313 + map.off('mousemove', onMove);
314 + map.off('mouseout', onLeave);
315 + map.off('click', onClick);
316 + };
317 + }, [ready, mode, router]);
318 +
319 + return (
320 + <div className="relative h-full w-full">
321 + <div ref={el} className="h-full w-full" aria-label="World map of Internet pressure" role="application" />
322 + {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 + )}
325 + </div>
326 + );
327 +}
added apps/web/src/components/map/modes.ts +20 −0
@@ -0,0 +1,20 @@
1 +import type { ComponentId } from '@/lib/types';
2 +
3 +export type MapMode = 'pressure' | 'latency' | 'loss' | 'dns' | 'routing' | 'availability' | 'incidents' | 'probes';
4 +
5 +export const MODES: { id: MapMode; label: string; component?: ComponentId }[] = [
6 + { id: 'pressure', label: 'Pressure' },
7 + { id: 'latency', label: 'Latency', component: 'latency' },
8 + { id: 'loss', label: 'Packet loss' },
9 + { id: 'dns', label: 'DNS', component: 'dns' },
10 + { id: 'routing', label: 'Routing', component: 'routing' },
11 + { id: 'availability', label: 'Availability', component: 'availability' },
12 + { id: 'incidents', label: 'Incidents' },
13 + { id: 'probes', label: 'Probe network' },
14 +];
15 +
16 +/** Packet loss (%) → pressure-like 0–100 so the same 7-colour scale applies (0 % calm … ≥ 8 % extreme). */
17 +export function lossToScale(lossPct: number | null | undefined): number | null {
18 + if (lossPct == null) return null;
19 + return Math.min(100, (lossPct / 8) * 100);
20 +}
added apps/web/src/components/ui/AnimatedNumber.tsx +43 −0
@@ -0,0 +1,43 @@
1 +'use client';
2 +
3 +import { useEffect, useRef, useState } from 'react';
4 +import { fmt } from '@/lib/format';
5 +
6 +/**
7 + * Tweens between values ONLY when `value` changes after mount (a real update arrived). No idle motion.
8 + * The first render prints the SSR value verbatim so there is no hydration flicker.
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 }) {
11 + const [display, setDisplay] = useState<number | null | undefined>(value);
12 + const fromRef = useRef<number | null | undefined>(value);
13 + const rafRef = useRef<number | null>(null);
14 +
15 + useEffect(() => {
16 + const from = fromRef.current;
17 + if (value == null || from == null || value === from || typeof window === 'undefined' || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
18 + fromRef.current = value;
19 + setDisplay(value);
20 + return;
21 + }
22 + const start = performance.now();
23 + const f = from;
24 + const step = (t: number) => {
25 + const p = Math.min(1, (t - start) / duration);
26 + const e = 1 - (1 - p) ** 3; // ease-out cubic
27 + setDisplay(f + (value - f) * e);
28 + if (p < 1) rafRef.current = requestAnimationFrame(step);
29 + else fromRef.current = value;
30 + };
31 + rafRef.current = requestAnimationFrame(step);
32 + return () => {
33 + if (rafRef.current) cancelAnimationFrame(rafRef.current);
34 + fromRef.current = value;
35 + };
36 + }, [value, duration]);
37 +
38 + return (
39 + <span className={`num ${className}`} style={style}>
40 + {fmt(display, digits)}
41 + </span>
42 + );
43 +}
added apps/web/src/components/ui/primitives.tsx +158 −0
@@ -0,0 +1,158 @@
1 +import type { ReactNode } from 'react';
2 +import { fmt, fmtDelta } from '@/lib/format';
3 +import { levelById, levelFor, pressureColor, trendArrow, type LEVELS } from '@/lib/pressure';
4 +import type { LevelId, Trend } from '@/lib/types';
5 +
6 +export function Section({ id, label, title, right, children, className = '' }: { id?: string; label?: string; title?: string; right?: ReactNode; children: ReactNode; className?: string }) {
7 + return (
8 + <section id={id} className={`hairline pt-4 pb-6 ${className}`}>
9 + {(label || title || right) && (
10 + <header className="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
11 + <div className="flex items-baseline gap-3">
12 + {label && <span className="label">{label}</span>}
13 + {title && <h2 className="text-[15px] font-medium text-ink">{title}</h2>}
14 + </div>
15 + {right && <div className="text-xs text-ink-2">{right}</div>}
16 + </header>
17 + )}
18 + {children}
19 + </section>
20 + );
21 +}
22 +
23 +export function LevelBadge({ level, value, size = 'sm' }: { level?: LevelId | string | null; value?: number | null; size?: 'xs' | 'sm' | 'md' }) {
24 + const l = level ? levelById(level) : value != null ? levelFor(value) : undefined;
25 + const color = l?.color ?? '#3A4756';
26 + const cls = size === 'xs' ? 'text-[10px] tracking-[0.1em]' : size === 'md' ? 'text-[13px] tracking-[0.12em]' : 'text-[11px] tracking-[0.12em]';
27 + return (
28 + <span className={`inline-flex items-center gap-1.5 font-medium uppercase ${cls}`} style={{ color }}>
29 + <span className="inline-block size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" />
30 + {l?.short ?? 'n/a'}
31 + </span>
32 + );
33 +}
34 +
35 +/** Pressure numeral coloured by level. */
36 +export function PNum({ value, digits = 1, className = '' }: { value: number | null | undefined; digits?: number; className?: string }) {
37 + return (
38 + <span className={`num ${className}`} style={{ color: value == null ? 'var(--ink-3)' : pressureColor(value) }}>
39 + {fmt(value, digits)}
40 + </span>
41 + );
42 +}
43 +
44 +/** Signed delta with arrow; rising pressure is warm, falling is cool. */
45 +export function Delta({ value, trend, digits = 1, suffix, className = '' }: { value: number | null | undefined; trend?: Trend; digits?: number; suffix?: string; className?: string }) {
46 + if (value == null) return <span className={`num text-ink-3 ${className}`}>—</span>;
47 + const arrow = trendArrow(trend, value);
48 + const color = value > 0.05 ? 'var(--p-stressed)' : value < -0.05 ? 'var(--p-calm)' : 'var(--ink-2)';
49 + return (
50 + <span className={`num ${className}`} style={{ color }}>
51 + {arrow} {fmtDelta(value, digits)}
52 + {suffix ? <span className="text-ink-3"> {suffix}</span> : null}
53 + </span>
54 + );
55 +}
56 +
57 +/** Thin horizontal bar 0–100 coloured by pressure (or a fixed colour). */
58 +export function Bar({ value, max = 100, color, height = 3, className = '' }: { value: number | null | undefined; max?: number; color?: string; height?: number; className?: string }) {
59 + const v = value == null ? 0 : Math.max(0, Math.min(max, value));
60 + return (
61 + <span className={`block w-full overflow-hidden bg-line/70 ${className}`} style={{ height }} aria-hidden="true">
62 + <span className="block h-full transition-[width] duration-700 ease-out" style={{ width: `${(v / max) * 100}%`, background: color ?? (value == null ? 'var(--ink-3)' : pressureColor(value)) }} />
63 + </span>
64 + );
65 +}
66 +
67 +export function ConfBar({ value, className = '' }: { value: number | null | undefined; className?: string }) {
68 + return (
69 + <span className={`inline-flex items-center gap-2 ${className}`}>
70 + <Bar value={value == null ? null : value * 100} color="var(--accent)" className="w-14" />
71 + <span className="num text-xs text-ink-2">{value == null ? '—' : `${Math.round(value * 100)} %`}</span>
72 + </span>
73 + );
74 +}
75 +
76 +/** Pure-SVG sparkline; renders on the server. Nulls break the line (missing data is shown as a gap, never interpolated). */
77 +export function Sparkline({ values, width = 240, height = 36, color = 'var(--accent)', bands = false, strokeWidth = 1.25, className = '' }: { values: (number | null)[]; width?: number; height?: number; color?: string; bands?: boolean; strokeWidth?: number; className?: string }) {
78 + const nums = values.filter((v): v is number => v != null);
79 + if (nums.length < 2) return <svg width={width} height={height} className={className} aria-hidden="true" />;
80 + const min = bands ? 0 : Math.min(...nums);
81 + const max = bands ? 100 : Math.max(...nums);
82 + const span = max - min || 1;
83 + const x = (i: number) => (i / (values.length - 1)) * (width - 2) + 1;
84 + const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4);
85 + let d = '';
86 + let pen = false;
87 + values.forEach((v, i) => {
88 + if (v == null) {
89 + pen = false;
90 + return;
91 + }
92 + d += `${pen ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)} `;
93 + pen = true;
94 + });
95 + const last = [...values].reverse().find((v) => v != null);
96 + const lastI = values.length - 1 - [...values].reverse().findIndex((v) => v != null);
97 + return (
98 + <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={className} aria-hidden="true" preserveAspectRatio="none">
99 + <path d={d} fill="none" stroke={color} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
100 + {last != null && <circle cx={x(lastI)} cy={y(last)} r={2} fill={color} />}
101 + </svg>
102 + );
103 +}
104 +
105 +export function Empty({ children = 'Not observed' }: { children?: ReactNode }) {
106 + return <p className="py-6 text-center text-xs text-ink-3">{children}</p>;
107 +}
108 +
109 +export function Stat({ label, value, sub, className = '' }: { label: string; value: ReactNode; sub?: ReactNode; className?: string }) {
110 + return (
111 + <div className={`min-w-0 ${className}`}>
112 + <div className="label truncate">{label}</div>
113 + <div className="num mt-0.5 text-[17px] leading-tight text-ink">{value}</div>
114 + {sub && <div className="mt-0.5 truncate text-[11px] text-ink-2">{sub}</div>}
115 + </div>
116 + );
117 +}
118 +
119 +export function StatusDot({ status }: { status: string }) {
120 + const color: Record<string, string> = { online: 'var(--ok)', stale: 'var(--warn)', offline: 'var(--bad)', excluded: 'var(--ink-3)', detected: 'var(--p-elevated)', developing: 'var(--p-stressed)', active: 'var(--p-high)', recovering: 'var(--p-calm)', resolved: 'var(--ink-3)', ok: 'var(--ok)', degraded: 'var(--warn)' };
121 + return (
122 + <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.1em]" style={{ color: color[status] ?? 'var(--ink-2)' }}>
123 + <span className="size-1.5 rounded-full" style={{ background: color[status] ?? 'var(--ink-2)' }} aria-hidden="true" />
124 + {status}
125 + </span>
126 + );
127 +}
128 +
129 +export function LevelLegend({ compact = false }: { compact?: boolean }) {
130 + return (
131 + <ul className={`flex flex-wrap items-center gap-x-3 gap-y-1 ${compact ? 'text-[10px]' : 'text-[11px]'} text-ink-2`}>
132 + {(
133 + [
134 + ['calm', '≤10'],
135 + ['normal', '≤25'],
136 + ['elevated', '≤40'],
137 + ['stressed', '≤55'],
138 + ['high', '≤70'],
139 + ['severe', '≤85'],
140 + ['extreme', '≤100'],
141 + ] as [LevelId, string][]
142 + ).map(([id, range]) => {
143 + const l = levelById(id) as (typeof LEVELS)[number];
144 + return (
145 + <li key={id} className="inline-flex items-center gap-1.5">
146 + <span className="size-2 rounded-[1px]" style={{ background: l.color }} aria-hidden="true" />
147 + <span>{l.short}</span>
148 + {!compact && <span className="num text-ink-3">{range}</span>}
149 + </li>
150 + );
151 + })}
152 + <li className="inline-flex items-center gap-1.5">
153 + <span className="size-2 rounded-[1px] border border-line-2 bg-neutral" aria-hidden="true" />
154 + <span>not observed</span>
155 + </li>
156 + </ul>
157 + );
158 +}
added apps/web/src/lib/admin-fetch.ts +55 −0
@@ -0,0 +1,55 @@
1 +'use client';
2 +
3 +/** Tiny client for /api/admin/* — token lives in sessionStorage only and travels as X-IP-Admin-Token. */
4 +const KEY = 'ip.admin-token';
5 +
6 +export function getAdminToken(): string {
7 + try {
8 + return window.sessionStorage.getItem(KEY) ?? '';
9 + } catch {
10 + return '';
11 + }
12 +}
13 +export function setAdminToken(token: string) {
14 + try {
15 + if (token) window.sessionStorage.setItem(KEY, token);
16 + else window.sessionStorage.removeItem(KEY);
17 + } catch {
18 + /* ignore */
19 + }
20 +}
21 +
22 +export class AdminError extends Error {
23 + constructor(
24 + public status: number,
25 + public body: unknown,
26 + ) {
27 + super(status === 401 ? 'Unauthorized' : `Admin API error ${status}`);
28 + }
29 +}
30 +
31 +export async function adminFetch<T>(path: string, init?: { method?: string; body?: unknown; params?: Record<string, string | number | undefined> }): Promise<T> {
32 + const qs = init?.params
33 + ? '?' +
34 + Object.entries(init.params)
35 + .filter(([, v]) => v !== undefined && v !== '')
36 + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
37 + .join('&')
38 + : '';
39 + const res = await fetch(`/api/admin${path}${qs}`, {
40 + method: init?.method ?? 'GET',
41 + headers: { 'X-IP-Admin-Token': getAdminToken(), ...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
42 + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
43 + cache: 'no-store',
44 + });
45 + if (!res.ok) {
46 + let body: unknown = null;
47 + try {
48 + body = await res.json();
49 + } catch {
50 + /* no body */
51 + }
52 + throw new AdminError(res.status, body);
53 + }
54 + return (await res.json()) as T;
55 +}
added apps/web/src/lib/api.ts +40 −0
@@ -0,0 +1,40 @@
1 +import 'server-only';
2 +import { notFound } from 'next/navigation';
3 +
4 +/**
5 + * Server-side API client. Server components talk to the FastAPI service directly (internal URL), never through the
6 + * browser rewrite. Every call is `no-store`: pages are dynamic and render the live state at request time.
7 + */
8 +const BASE = process.env.API_URL_INTERNAL ?? process.env.API_URL ?? 'http://127.0.0.1:8352';
9 +
10 +export class ApiError extends Error {
11 + constructor(
12 + public status: number,
13 + public path: string,
14 + ) {
15 + super(`API ${status} for ${path}`);
16 + }
17 +}
18 +
19 +export async function apiGet<T>(path: string, init?: { timeoutMs?: number }): Promise<T> {
20 + const ctrl = new AbortController();
21 + const timer = setTimeout(() => ctrl.abort(), init?.timeoutMs ?? 8000);
22 + try {
23 + const res = await fetch(BASE + path, { cache: 'no-store', signal: ctrl.signal, headers: { accept: 'application/json' } });
24 + if (res.status === 404) notFound();
25 + if (!res.ok) throw new ApiError(res.status, path);
26 + return (await res.json()) as T;
27 + } finally {
28 + clearTimeout(timer);
29 + }
30 +}
31 +
32 +/** Like apiGet but returns null instead of throwing when the API is unreachable/erroring (used for optional panels). */
33 +export async function apiTry<T>(path: string): Promise<T | null> {
34 + try {
35 + return await apiGet<T>(path);
36 + } catch (e) {
37 + if (e && typeof e === 'object' && 'digest' in e && String((e as { digest?: string }).digest).startsWith('NEXT_HTTP_ERROR_FALLBACK')) throw e;
38 + return null;
39 + }
40 +}
added apps/web/src/lib/format.ts +106 −0
@@ -0,0 +1,106 @@
1 +/** Formatting helpers. All numerals are rendered in mono/tabular; all timestamps go through formatTime(). */
2 +
3 +export type TimeMode = 'utc' | 'local';
4 +
5 +const nf = (digits: number) => new Intl.NumberFormat('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
6 +const NF0 = nf(0);
7 +const NF1 = nf(1);
8 +const NF2 = nf(2);
9 +
10 +export function fmt(v: number | null | undefined, digits = 1): string {
11 + if (v == null || Number.isNaN(v)) return '—';
12 + return digits === 0 ? NF0.format(v) : digits === 2 ? NF2.format(v) : digits === 1 ? NF1.format(v) : nf(digits).format(v);
13 +}
14 +export const fmtInt = (v: number | null | undefined) => fmt(v, 0);
15 +
16 +/** Signed delta with a true minus sign: +6.3 / −2.1 / 0.0 */
17 +export function fmtDelta(v: number | null | undefined, digits = 1): string {
18 + if (v == null || Number.isNaN(v)) return '—';
19 + const s = fmt(Math.abs(v), digits);
20 + if (v > 0) return `+${s}`;
21 + if (v < 0) return `−${s}`;
22 + return s;
23 +}
24 +
25 +export function fmtPct(ratio: number | null | undefined, digits = 0): string {
26 + if (ratio == null || Number.isNaN(ratio)) return '—';
27 + return `${fmt(ratio * 100, digits)} %`;
28 +}
29 +
30 +export function fmtMs(v: number | null | undefined, digits = 0): string {
31 + if (v == null || Number.isNaN(v)) return '—';
32 + return `${fmt(v, digits)} ms`;
33 +}
34 +
35 +export function fmtBytes(b: number | null | undefined): string {
36 + if (b == null) return '—';
37 + const u = ['B', 'KB', 'MB', 'GB', 'TB'];
38 + let i = 0;
39 + let v = b;
40 + while (v >= 1024 && i < u.length - 1) {
41 + v /= 1024;
42 + i++;
43 + }
44 + return `${fmt(v, i === 0 ? 0 : 1)} ${u[i]}`;
45 +}
46 +
47 +export function fmtRatio(v: number | null | undefined): string {
48 + if (v == null || Number.isNaN(v)) return '—';
49 + return `${fmt(v, v >= 10 ? 0 : 1)}×`;
50 +}
51 +
52 +export function fmtDuration(seconds: number | null | undefined): string {
53 + if (seconds == null || Number.isNaN(seconds)) return '—';
54 + const s = Math.max(0, Math.round(seconds));
55 + if (s < 60) return `${s} s`;
56 + const m = Math.floor(s / 60);
57 + if (m < 60) return `${m} min`;
58 + const h = Math.floor(m / 60);
59 + const rm = m % 60;
60 + if (h < 48) return rm ? `${h} h ${rm} min` : `${h} h`;
61 + const d = Math.floor(h / 24);
62 + return `${d} d ${h % 24} h`;
63 +}
64 +
65 +/** "3 s ago", "2 min ago", "4 h ago" — relative to `nowMs`. */
66 +export function fmtAgo(ts: string | number | Date | null | undefined, nowMs = Date.now()): string {
67 + if (!ts) return '—';
68 + const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
69 + if (Number.isNaN(t)) return '—';
70 + const s = Math.max(0, Math.round((nowMs - t) / 1000));
71 + if (s < 60) return `${s} s ago`;
72 + const m = Math.floor(s / 60);
73 + if (m < 60) return `${m} min ago`;
74 + const h = Math.floor(m / 60);
75 + if (h < 48) return `${h} h ago`;
76 + return `${Math.floor(h / 24)} d ago`;
77 +}
78 +
79 +export type TimeStyle = 'time' | 'short' | 'full' | 'date' | 'month';
80 +
81 +/** Single time formatter. UTC is the default (spec §67: UTC storage, local display with a toggle). */
82 +export function formatTime(ts: string | number | Date | null | undefined, mode: TimeMode = 'utc', style: TimeStyle = 'short'): string {
83 + if (!ts) return '—';
84 + const d = ts instanceof Date ? ts : new Date(ts);
85 + if (Number.isNaN(d.getTime())) return '—';
86 + const tz = mode === 'utc' ? 'UTC' : undefined;
87 + const opts: Intl.DateTimeFormatOptions =
88 + style === 'time'
89 + ? { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz }
90 + : style === 'date'
91 + ? { year: 'numeric', month: 'short', day: '2-digit', timeZone: tz }
92 + : style === 'month'
93 + ? { year: 'numeric', month: 'long', timeZone: tz }
94 + : style === 'full'
95 + ? { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz, timeZoneName: 'short' }
96 + : { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: tz };
97 + let out = new Intl.DateTimeFormat('en-GB', opts).format(d);
98 + if (mode === 'utc' && (style === 'short' || style === 'time')) out += ' UTC';
99 + return out;
100 +}
101 +
102 +export function isoDate(ts: string | Date): string {
103 + return (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
104 +}
105 +
106 +export const MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
added apps/web/src/lib/geo.ts +28 −0
@@ -0,0 +1,28 @@
1 +/** Great-circle interpolation for Pressure Front arcs (spherical linear interpolation, N points). */
2 +export function greatCircle(from: [number, number], to: [number, number], n = 64): [number, number][] {
3 + const toRad = Math.PI / 180;
4 + const toDeg = 180 / Math.PI;
5 + const [lon1, lat1] = [from[0] * toRad, from[1] * toRad];
6 + const [lon2, lat2] = [to[0] * toRad, to[1] * toRad];
7 + const d = 2 * Math.asin(Math.sqrt(Math.sin((lat2 - lat1) / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin((lon2 - lon1) / 2) ** 2));
8 + if (d === 0) return [from, to];
9 + const pts: [number, number][] = [];
10 + for (let i = 0; i <= n; i++) {
11 + const f = i / n;
12 + const A = Math.sin((1 - f) * d) / Math.sin(d);
13 + const B = Math.sin(f * d) / Math.sin(d);
14 + const x = A * Math.cos(lat1) * Math.cos(lon1) + B * Math.cos(lat2) * Math.cos(lon2);
15 + const y = A * Math.cos(lat1) * Math.sin(lon1) + B * Math.cos(lat2) * Math.sin(lon2);
16 + const z = A * Math.sin(lat1) + B * Math.sin(lat2);
17 + const lat = Math.atan2(z, Math.sqrt(x * x + y * y)) * toDeg;
18 + let lon = Math.atan2(y, x) * toDeg;
19 + // keep the line continuous when it would cross the antimeridian
20 + if (pts.length) {
21 + const prev = pts[pts.length - 1]![0];
22 + if (lon - prev > 180) lon -= 360;
23 + else if (prev - lon > 180) lon += 360;
24 + }
25 + pts.push([lon, lat]);
26 + }
27 + return pts;
28 +}
added apps/web/src/lib/iso-numeric-to-alpha2.ts +30 −0
@@ -0,0 +1,30 @@
1 +/** ISO 3166-1 numeric → alpha-2, for joining world-atlas (numeric ids) with the API (alpha-2 `cc`). */
2 +export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
3 + '004': 'AF', '008': 'AL', '010': 'AQ', '012': 'DZ', '016': 'AS', '020': 'AD', '024': 'AO', '028': 'AG', '031': 'AZ', '032': 'AR', '036': 'AU', '040': 'AT',
4 + '044': 'BS', '048': 'BH', '050': 'BD', '051': 'AM', '052': 'BB', '056': 'BE', '060': 'BM', '064': 'BT', '068': 'BO', '070': 'BA', '072': 'BW', '076': 'BR',
5 + '084': 'BZ', '086': 'IO', '090': 'SB', '092': 'VG', '096': 'BN', '100': 'BG', '104': 'MM', '108': 'BI', '112': 'BY', '116': 'KH', '120': 'CM', '124': 'CA',
6 + '132': 'CV', '136': 'KY', '140': 'CF', '144': 'LK', '148': 'TD', '152': 'CL', '156': 'CN', '158': 'TW', '162': 'CX', '166': 'CC', '170': 'CO', '174': 'KM',
7 + '175': 'YT', '178': 'CG', '180': 'CD', '184': 'CK', '188': 'CR', '191': 'HR', '192': 'CU', '196': 'CY', '203': 'CZ', '204': 'BJ', '208': 'DK', '212': 'DM',
8 + '214': 'DO', '218': 'EC', '222': 'SV', '226': 'GQ', '231': 'ET', '232': 'ER', '233': 'EE', '234': 'FO', '238': 'FK', '242': 'FJ', '246': 'FI', '248': 'AX',
9 + '250': 'FR', '254': 'GF', '258': 'PF', '260': 'TF', '262': 'DJ', '266': 'GA', '268': 'GE', '270': 'GM', '275': 'PS', '276': 'DE', '288': 'GH', '292': 'GI',
10 + '296': 'KI', '300': 'GR', '304': 'GL', '308': 'GD', '312': 'GP', '316': 'GU', '320': 'GT', '324': 'GN', '328': 'GY', '332': 'HT', '336': 'VA', '340': 'HN',
11 + '344': 'HK', '348': 'HU', '352': 'IS', '356': 'IN', '360': 'ID', '364': 'IR', '368': 'IQ', '372': 'IE', '376': 'IL', '380': 'IT', '384': 'CI', '388': 'JM',
12 + '392': 'JP', '398': 'KZ', '400': 'JO', '404': 'KE', '408': 'KP', '410': 'KR', '414': 'KW', '417': 'KG', '418': 'LA', '422': 'LB', '426': 'LS', '428': 'LV',
13 + '430': 'LR', '434': 'LY', '438': 'LI', '440': 'LT', '442': 'LU', '446': 'MO', '450': 'MG', '454': 'MW', '458': 'MY', '462': 'MV', '466': 'ML', '470': 'MT',
14 + '474': 'MQ', '478': 'MR', '480': 'MU', '484': 'MX', '492': 'MC', '496': 'MN', '498': 'MD', '499': 'ME', '500': 'MS', '504': 'MA', '508': 'MZ', '512': 'OM',
15 + '516': 'NA', '520': 'NR', '524': 'NP', '528': 'NL', '540': 'NC', '548': 'VU', '554': 'NZ', '558': 'NI', '562': 'NE', '566': 'NG', '570': 'NU', '574': 'NF',
16 + '578': 'NO', '580': 'MP', '581': 'UM', '583': 'FM', '584': 'MH', '585': 'PW', '586': 'PK', '591': 'PA', '598': 'PG', '600': 'PY', '604': 'PE', '608': 'PH',
17 + '612': 'PN', '616': 'PL', '620': 'PT', '624': 'GW', '626': 'TL', '630': 'PR', '634': 'QA', '638': 'RE', '642': 'RO', '643': 'RU', '646': 'RW', '652': 'BL',
18 + '654': 'SH', '659': 'KN', '660': 'AI', '662': 'LC', '663': 'MF', '666': 'PM', '670': 'VC', '674': 'SM', '678': 'ST', '682': 'SA', '686': 'SN', '688': 'RS',
19 + '690': 'SC', '694': 'SL', '702': 'SG', '703': 'SK', '704': 'VN', '705': 'SI', '706': 'SO', '710': 'ZA', '716': 'ZW', '724': 'ES', '728': 'SS', '729': 'SD',
20 + '732': 'EH', '740': 'SR', '744': 'SJ', '748': 'SZ', '752': 'SE', '756': 'CH', '760': 'SY', '762': 'TJ', '764': 'TH', '768': 'TG', '772': 'TK', '776': 'TO',
21 + '780': 'TT', '784': 'AE', '788': 'TN', '792': 'TR', '795': 'TM', '796': 'TC', '798': 'TV', '800': 'UG', '804': 'UA', '807': 'MK', '818': 'EG', '826': 'GB',
22 + '831': 'GG', '832': 'JE', '833': 'IM', '834': 'TZ', '840': 'US', '850': 'VI', '854': 'BF', '858': 'UY', '860': 'UZ', '862': 'VE', '876': 'WF', '882': 'WS',
23 + '887': 'YE', '894': 'ZM', '-99': 'XK',
24 +};
25 +
26 +export function numericToAlpha2(id: string | number | undefined | null): string | undefined {
27 + if (id == null) return undefined;
28 + const k = String(id).padStart(3, '0');
29 + return ISO_NUMERIC_TO_ALPHA2[k] ?? ISO_NUMERIC_TO_ALPHA2[String(id)];
30 +}
added apps/web/src/lib/live.tsx +272 −0
@@ -0,0 +1,272 @@
1 +'use client';
2 +
3 +import { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
4 +import type {
5 + BgpStatsEvent,
6 + Front,
7 + GlobalPressure,
8 + Incident,
9 + InternalStatus,
10 + InternalStatusEvent,
11 + LevelId,
12 + ProbeStats,
13 + Region,
14 + RegionalUpdate,
15 + ServiceDegradationEvent,
16 + Snapshot,
17 + Ticker,
18 +} from './types';
19 +
20 +/**
21 + * One EventSource per page. State is kept in a tiny external store split into slices; components subscribe with
22 + * `useLive(selector)` so an SSE message re-renders only the islands whose slice changed (spec §51).
23 + * Nothing here ever invents motion: every state change corresponds to a received event.
24 + */
25 +export interface CountryLite {
26 + cc: string;
27 + pressure: number;
28 + level: LevelId;
29 + delta_1h: number;
30 +}
31 +export interface LiveState {
32 + global: GlobalPressure | null;
33 + ticker: Ticker | null;
34 + regions: Region[] | null;
35 + countries: CountryLite[] | null;
36 + fronts: Front[] | null;
37 + incidents: Incident[] | null;
38 + bgp: BgpStatsEvent | null;
39 + probeStats: ProbeStats | null;
40 + internalStatus: InternalStatus;
41 + internalReason: string | null;
42 + serviceDegradations: ServiceDegradationEvent[];
43 + /** ms timestamp of the last *data* event received (not pings). */
44 + lastEventAt: number | null;
45 + /** ISO ts carried by the last engine update — the "frozen since" reference when degraded. */
46 + lastEngineTs: string | null;
47 + connection: 'idle' | 'connecting' | 'open' | 'reconnecting';
48 + /** How many real updates arrived (used to gate number animations: never animate the SSR value). */
49 + updates: number;
50 +}
51 +
52 +export interface LiveInitial {
53 + global?: GlobalPressure | null;
54 + ticker?: Ticker | null;
55 + regions?: Region[] | null;
56 + fronts?: Front[] | null;
57 + incidents?: Incident[] | null;
58 + bgp?: BgpStatsEvent | null;
59 +}
60 +
61 +type Listener = () => void;
62 +
63 +class LiveStore {
64 + state: LiveState;
65 + private listeners = new Set<Listener>();
66 + constructor(initial: LiveInitial) {
67 + const g = initial.global ?? null;
68 + this.state = {
69 + global: g,
70 + ticker: initial.ticker ?? null,
71 + regions: initial.regions ?? null,
72 + countries: null,
73 + fronts: initial.fronts ?? null,
74 + incidents: initial.incidents ?? null,
75 + bgp: initial.bgp ?? null,
76 + probeStats: null,
77 + internalStatus: g?.internal_status ?? initial.ticker?.internal_status ?? 'ok',
78 + internalReason: null,
79 + serviceDegradations: [],
80 + lastEventAt: null,
81 + lastEngineTs: g?.ts ?? null,
82 + connection: 'idle',
83 + updates: 0,
84 + };
85 + }
86 + subscribe = (l: Listener) => {
87 + this.listeners.add(l);
88 + return () => {
89 + this.listeners.delete(l);
90 + };
91 + };
92 + get = () => this.state;
93 + set(patch: Partial<LiveState>, isData = true) {
94 + this.state = { ...this.state, ...patch, ...(isData ? { lastEventAt: Date.now(), updates: this.state.updates + 1 } : {}) };
95 + for (const l of this.listeners) l();
96 + }
97 +}
98 +
99 +const Ctx = createContext<LiveStore | null>(null);
100 +
101 +function mergeIncident(list: Incident[] | null, inc: Incident): Incident[] {
102 + const rest = (list ?? []).filter((i) => i.event_id !== inc.event_id);
103 + const next = inc.status === 'resolved' ? rest : [inc, ...rest];
104 + return next.sort((a, b) => b.current_pressure - a.current_pressure);
105 +}
106 +
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;
111 +
112 + useEffect(() => {
113 + if (!enabled || typeof window === 'undefined' || typeof EventSource === 'undefined') return;
114 + let es: EventSource | null = null;
115 + let attempt = 0;
116 + let timer: ReturnType<typeof setTimeout> | null = null;
117 + let closed = false;
118 +
119 + const parse = <T,>(e: MessageEvent): T | null => {
120 + try {
121 + return JSON.parse(e.data) as T;
122 + } catch {
123 + return null;
124 + }
125 + };
126 +
127 + const open = () => {
128 + if (closed) return;
129 + store.set({ connection: attempt === 0 ? 'connecting' : 'reconnecting' }, false);
130 + es = new EventSource('/api/v1/live');
131 + es.onopen = () => {
132 + attempt = 0;
133 + store.set({ connection: 'open' }, false);
134 + };
135 + es.onerror = () => {
136 + // EventSource retries on its own while CONNECTING; when the browser gives up (CLOSED) we back off and recreate.
137 + if (es && es.readyState === EventSource.CLOSED) {
138 + es.close();
139 + es = null;
140 + attempt++;
141 + const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt, 5)) + Math.random() * 500;
142 + store.set({ connection: 'reconnecting' }, false);
143 + timer = setTimeout(open, delay);
144 + } else {
145 + store.set({ connection: 'reconnecting' }, false);
146 + }
147 + };
148 + es.addEventListener('snapshot', (e) => {
149 + const s = parse<Snapshot>(e as MessageEvent);
150 + if (!s) return;
151 + store.set({
152 + global: s.global,
153 + ticker: s.ticker,
154 + regions: s.regions,
155 + fronts: s.fronts,
156 + incidents: s.incidents,
157 + internalStatus: s.global?.internal_status ?? s.ticker?.internal_status ?? store.state.internalStatus,
158 + lastEngineTs: s.global?.ts ?? store.state.lastEngineTs,
159 + });
160 + });
161 + es.addEventListener('global_pressure_update', (e) => {
162 + const g = parse<GlobalPressure>(e as MessageEvent);
163 + if (!g) return;
164 + store.set({ global: g, internalStatus: g.internal_status, lastEngineTs: g.ts, internalReason: g.internal_status === 'ok' ? null : store.state.internalReason });
165 + });
166 + es.addEventListener('regional_pressure_update', (e) => {
167 + const u = parse<RegionalUpdate>(e as MessageEvent);
168 + if (!u) return;
169 + store.set({ regions: u.regions, countries: u.countries });
170 + });
171 + es.addEventListener('ticker', (e) => {
172 + const t = parse<Ticker>(e as MessageEvent);
173 + if (!t) return;
174 + store.set({ ticker: t, internalStatus: t.internal_status ?? store.state.internalStatus });
175 + });
176 + es.addEventListener('bgp_stats', (e) => {
177 + const b = parse<BgpStatsEvent>(e as MessageEvent);
178 + if (b) store.set({ bgp: b });
179 + });
180 + es.addEventListener('probe_stats', (e) => {
181 + const p = parse<ProbeStats>(e as MessageEvent);
182 + if (p) store.set({ probeStats: p });
183 + });
184 + es.addEventListener('front_update', (e) => {
185 + const f = parse<{ fronts: Front[] }>(e as MessageEvent);
186 + if (f?.fronts) store.set({ fronts: f.fronts });
187 + });
188 + const onIncident = (e: Event) => {
189 + const inc = parse<Incident>(e as MessageEvent);
190 + if (inc) store.set({ incidents: mergeIncident(store.state.incidents, inc) });
191 + };
192 + es.addEventListener('incident_created', onIncident);
193 + es.addEventListener('incident_updated', onIncident);
194 + es.addEventListener('service_degradation', (e) => {
195 + const d = parse<ServiceDegradationEvent>(e as MessageEvent);
196 + if (d) store.set({ serviceDegradations: [d, ...store.state.serviceDegradations.filter((x) => x.slug !== d.slug)].slice(0, 12) });
197 + });
198 + es.addEventListener('internal_status', (e) => {
199 + const s = parse<InternalStatusEvent>(e as MessageEvent);
200 + if (s) store.set({ internalStatus: s.internal_status, internalReason: s.reason ?? null }, false);
201 + });
202 + };
203 + open();
204 + return () => {
205 + closed = true;
206 + if (timer) clearTimeout(timer);
207 + es?.close();
208 + };
209 + }, [store, enabled]);
210 +
211 + return <Ctx.Provider value={store}>{children}</Ctx.Provider>;
212 +}
213 +
214 +const EMPTY = new LiveStore({});
215 +
216 +function shallowEqual(a: unknown, b: unknown): boolean {
217 + if (Object.is(a, b)) return true;
218 + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
219 + const ka = Object.keys(a as object);
220 + const kb = Object.keys(b as object);
221 + if (ka.length !== kb.length) return false;
222 + for (const k of ka) if (!Object.is((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
223 + return true;
224 +}
225 +
226 +/**
227 + * Subscribe to a slice. Selectors may return fresh objects: the result is cached per store state and
228 + * shallow-compared so useSyncExternalStore sees a stable snapshot (no re-render unless the slice changed).
229 + */
230 +export function useLive<T>(selector: (s: LiveState) => T): T {
231 + const store = useContext(Ctx) ?? EMPTY;
232 + const cache = useRef<{ state: LiveState; result: T } | null>(null);
233 + const read = () => {
234 + const state = store.get();
235 + const c = cache.current;
236 + if (c && c.state === state) return c.result;
237 + const result = selector(state);
238 + if (c && shallowEqual(c.result, result)) {
239 + cache.current = { state, result: c.result };
240 + return c.result;
241 + }
242 + cache.current = { state, result };
243 + return result;
244 + };
245 + return useSyncExternalStore(store.subscribe, read, read);
246 +}
247 +
248 +/** True when the instrument itself is unhealthy: the number must be shown dimmed and never read as an Internet event. */
249 +export function useDegraded(): { degraded: boolean; status: InternalStatus; reason: string | null; frozenSince: string | null } {
250 + return useLive((s) => ({
251 + degraded: s.internalStatus !== 'ok' || Boolean(s.global?.stale),
252 + status: s.internalStatus,
253 + reason: s.internalReason,
254 + frozenSince: s.lastEngineTs,
255 + }));
256 +}
257 +
258 +/** A clock that ticks every `ms` for relative labels ("3 s ago"). The text updates, the data does not. */
259 +export function useNow(ms = 1000): number {
260 + const [now, setNow] = useState(() => Date.now());
261 + useEffect(() => {
262 + const id = setInterval(() => setNow(Date.now()), ms);
263 + return () => clearInterval(id);
264 + }, [ms]);
265 + return now;
266 +}
267 +
268 +/** Stable helper for components that want the initial (SSR) value merged with the live slice. */
269 +export function useLiveOr<T>(selector: (s: LiveState) => T | null | undefined, fallback: T): T {
270 + const v = useLive(selector);
271 + return useMemo(() => v ?? fallback, [v, fallback]);
272 +}
added apps/web/src/lib/pressure.ts +66 −0
@@ -0,0 +1,66 @@
1 +import type { ComponentId, LevelId, Trend } from './types';
2 +
3 +/** Pressure scale — mirrors packages/config/pressure.yaml (ids/max) and the design palette (colours). */
4 +export const LEVELS: { id: LevelId; max: number; label: string; short: string; color: string }[] = [
5 + { id: 'calm', max: 10, label: 'Exceptionally calm', short: 'Calm', color: '#4CC9F0' },
6 + { id: 'normal', max: 25, label: 'Normal', short: 'Normal', color: '#7FB77E' },
7 + { id: 'elevated', max: 40, label: 'Elevated', short: 'Elevated', color: '#E9C46A' },
8 + { id: 'stressed', max: 55, label: 'Stressed', short: 'Stressed', color: '#F4A261' },
9 + { id: 'high', max: 70, label: 'Highly stressed', short: 'High', color: '#E76F51' },
10 + { id: 'severe', max: 85, label: 'Severe disruption', short: 'Severe', color: '#D62828' },
11 + { id: 'extreme', max: 100, label: 'Extreme Internet event', short: 'Extreme', color: '#F72585' },
12 +];
13 +
14 +export const NEUTRAL = '#0F151D';
15 +export const ACCENT = '#5B8DEF';
16 +
17 +export function levelFor(value: number | null | undefined): (typeof LEVELS)[number] {
18 + if (value == null || Number.isNaN(value)) return LEVELS[1]!;
19 + return LEVELS.find((l) => value <= l.max) ?? LEVELS[LEVELS.length - 1]!;
20 +}
21 +
22 +export function levelById(id: LevelId | string | null | undefined) {
23 + return LEVELS.find((l) => l.id === id);
24 +}
25 +
26 +/** Colour for a level id or a numeric pressure value. */
27 +export function pressureColor(levelOrValue: LevelId | number | null | undefined): string {
28 + if (levelOrValue == null) return '#3A4756';
29 + if (typeof levelOrValue === 'number') return levelFor(levelOrValue).color;
30 + return levelById(levelOrValue)?.color ?? '#3A4756';
31 +}
32 +
33 +export function levelWord(levelOrValue: LevelId | number | null | undefined): string {
34 + if (levelOrValue == null) return '—';
35 + const l = typeof levelOrValue === 'number' ? levelFor(levelOrValue) : levelById(levelOrValue);
36 + return (l?.short ?? '—').toUpperCase();
37 +}
38 +
39 +export const COMPONENT_ORDER: ComponentId[] = ['routing', 'latency', 'dns', 'availability', 'http_tls', 'path', 'corroboration'];
40 +export const COMPONENT_LABEL: Record<ComponentId, string> = {
41 + routing: 'Routing',
42 + latency: 'Latency',
43 + dns: 'DNS',
44 + availability: 'Availability',
45 + http_tls: 'HTTP/TLS',
46 + path: 'Path',
47 + corroboration: 'Corroboration',
48 +};
49 +
50 +export function trendArrow(trend: Trend | undefined, delta?: number): string {
51 + if (trend === 'rising' || (trend === undefined && (delta ?? 0) > 0)) return '↑';
52 + if (trend === 'falling' || (trend === undefined && (delta ?? 0) < 0)) return '↓';
53 + return '→';
54 +}
55 +
56 +export const STATUS_COLOR: Record<string, string> = {
57 + detected: '#E9C46A',
58 + developing: '#F4A261',
59 + active: '#E76F51',
60 + recovering: '#4CC9F0',
61 + resolved: '#8B98A5',
62 + online: '#7FB77E',
63 + stale: '#E9C46A',
64 + offline: '#D62828',
65 + excluded: '#8B98A5',
66 +};
added apps/web/src/lib/site.ts +7 −0
@@ -0,0 +1,7 @@
1 +export const SITE_NAME = 'InternetPressure.io';
2 +export const TAGLINE = 'The real-time pressure gauge for the Internet';
3 +export const DESCRIPTION =
4 + 'A live, independent measure of how stressed the public Internet is right now: routing, latency, DNS, availability, HTTP/TLS and path signals from our own probe network and open BGP telemetry, synthesised into one explainable 0–100 index.';
5 +export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.internetpressure.io';
6 +export const CONTACT_EMAIL = 'contact@spboucher.ai';
7 +export const AUTHOR = 'Simon-Pierre Boucher';
added apps/web/src/lib/time.tsx +50 −0
@@ -0,0 +1,50 @@
1 +'use client';
2 +
3 +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
4 +import { formatTime, type TimeMode, type TimeStyle } from './format';
5 +
6 +interface TimeCtx {
7 + mode: TimeMode;
8 + setMode: (m: TimeMode) => void;
9 + format: (ts: string | number | Date | null | undefined, style?: TimeStyle) => string;
10 +}
11 +
12 +const Ctx = createContext<TimeCtx>({ mode: 'utc', setMode: () => {}, format: (ts, style) => formatTime(ts, 'utc', style) });
13 +const KEY = 'ip.time-mode';
14 +
15 +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 + }, []);
34 + const value = useMemo<TimeCtx>(() => ({ mode, setMode, format: (ts, style) => formatTime(ts, mode, style) }), [mode, setMode]);
35 + return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
36 +}
37 +
38 +export function useTime() {
39 + return useContext(Ctx);
40 +}
41 +
42 +/** Inline timestamp that follows the UTC/local toggle. */
43 +export function Time({ ts, style = 'short', className }: { ts: string | number | Date | null | undefined; style?: TimeStyle; className?: string }) {
44 + const { format } = useTime();
45 + return (
46 + <time dateTime={ts ? new Date(ts).toISOString() : undefined} className={className} suppressHydrationWarning>
47 + {format(ts, style)}
48 + </time>
49 + );
50 +}
added apps/web/src/lib/types.ts +646 −0
@@ -0,0 +1,646 @@
1 +/**
2 + * TypeScript mirror of docs/API.md (public v1 + admin). Field names and shapes follow the contract exactly;
3 + * nothing here is invented — if the UI needs a field that is not in API.md it is listed in the README instead.
4 + */
5 +
6 +export type LevelId = 'calm' | 'normal' | 'elevated' | 'stressed' | 'high' | 'severe' | 'extreme';
7 +export type Trend = 'rising' | 'falling' | 'stable';
8 +export type InternalStatus = 'ok' | 'degraded' | 'stale';
9 +export type ComponentId = 'routing' | 'latency' | 'dns' | 'availability' | 'http_tls' | 'path' | 'corroboration';
10 +export type IncidentStatus = 'detected' | 'developing' | 'active' | 'recovering' | 'resolved';
11 +export type IncidentType =
12 + | 'regional_latency'
13 + | 'dns_disruption'
14 + | 'routing_instability'
15 + | 'service_degradation'
16 + | 'availability_loss'
17 + | 'path_instability'
18 + | 'global_pressure';
19 +export type ScopeType = 'global' | 'region' | 'country' | 'asn' | 'service' | 'component' | 'bgp' | 'signal' | 'target';
20 +export type HistoryRange = '1h' | '6h' | '24h' | '7d' | '30d' | '1y';
21 +
22 +export interface Status {
23 + ok: boolean;
24 + ts: string;
25 + internal_status: InternalStatus;
26 + engine: { last_run: string; cycle_ms: number; cycle_seconds: number };
27 + ingest: { last_batch: string; batches_5m: number; measurements_5m: number };
28 + probes: { fresh: number; total: number; excluded: string[] };
29 + bgp: { fresh: boolean; last_message: string; collectors: number };
30 + stores: { clickhouse: boolean; postgres: boolean; redis: boolean };
31 + version: string;
32 +}
33 +
34 +export interface Driver {
35 + label: string;
36 + points: number;
37 + scope_type: string;
38 + scope_id: string | null;
39 +}
40 +
41 +export interface Component {
42 + id: ComponentId;
43 + label: string;
44 + score: number;
45 + weight: number;
46 + contribution: number;
47 + trend: Trend;
48 + delta_1h: number;
49 + confidence: number;
50 + drivers: Driver[];
51 +}
52 +
53 +export interface ExplainRow {
54 + text: string;
55 + points: number;
56 + component: ComponentId;
57 + scope_type: string;
58 + scope_id: string | null;
59 +}
60 +
61 +export interface Coverage {
62 + probes_active: number;
63 + probes_total: number;
64 + probe_regions: number;
65 + targets: number;
66 + measurements_5m: number;
67 + bgp_collectors: number;
68 + baseline_days: number;
69 +}
70 +
71 +export interface GlobalPressure {
72 + ts: string;
73 + pressure: number;
74 + level: LevelId;
75 + level_label: string;
76 + delta_1h: number;
77 + delta_24h: number;
78 + velocity_per_h: number;
79 + acceleration_per_h2: number;
80 + volatility_1h: number;
81 + trend: Trend;
82 + confidence: number;
83 + stale: boolean;
84 + internal_status: InternalStatus;
85 + coverage: Coverage;
86 + components: Component[];
87 + explain: ExplainRow[];
88 + sparkline_1h: (number | null)[];
89 +}
90 +
91 +export type ComponentScores = Partial<Record<ComponentId, number | null>>;
92 +
93 +export interface HistoryPoint {
94 + ts: string;
95 + pressure: number;
96 + components: ComponentScores;
97 + confidence: number;
98 +}
99 +export interface History {
100 + scope_type: string;
101 + scope_id: string | null;
102 + range: HistoryRange;
103 + step_seconds: number;
104 + points: HistoryPoint[];
105 + summary: { min: number; max: number; avg: number; max_ts: string };
106 +}
107 +
108 +export interface SimpleSeries {
109 + step_seconds: number;
110 + points: { ts: string; pressure: number }[];
111 +}
112 +
113 +export interface Region {
114 + id: string;
115 + name: string;
116 + continent: string;
117 + lat: number;
118 + lon: number;
119 + pressure: number;
120 + level: LevelId;
121 + level_label: string;
122 + delta_1h: number;
123 + trend: Trend;
124 + confidence: number;
125 + components: ComponentScores;
126 + probes: number;
127 + targets: number;
128 + incidents: number;
129 + coverage_ok: boolean;
130 + role: 'probe' | 'target' | 'both';
131 +}
132 +
133 +export interface Probe {
134 + probe_id: string;
135 + name: string;
136 + region: string;
137 + country: string;
138 + city: string;
139 + provider: string;
140 + asn: number;
141 + lat: number;
142 + lon: number;
143 + status: 'online' | 'stale' | 'offline' | 'excluded';
144 + last_seen: string;
145 + version: string;
146 + measurements_1h: number;
147 + uptime_24h: number;
148 + clock_offset_ms: number;
149 + capabilities: string[];
150 +}
151 +
152 +export interface LatencyPair {
153 + from: string;
154 + to: string;
155 + rtt_ms: number;
156 + rtt_ms_baseline: number;
157 + ttfb_ms: number;
158 + loss_pct: number;
159 + z: number;
160 + pairs: number;
161 +}
162 +export interface Latency {
163 + ts: string;
164 + global: { rtt_ms_median: number; rtt_ms_baseline: number; ttfb_ms_median: number; ttfb_ms_baseline: number; packet_loss_pct: number };
165 + matrix: LatencyPair[];
166 + by_probe: { probe_id: string; rtt_ms_median: number; ttfb_ms_median: number; loss_pct: number; z: number }[];
167 +}
168 +
169 +export interface TargetRow {
170 + target_id: string;
171 + name: string;
172 + pressure: number;
173 + ok_ratio_1h: number;
174 + ttfb_ms_median: number;
175 +}
176 +export interface CountryTargetRow extends TargetRow {
177 + category: string;
178 +}
179 +
180 +// `probes`/`incidents` are counts in the list object and lists in the detail object (API.md §region/{id}).
181 +export type RegionDetailResponse = Omit<Region, 'probes' | 'incidents'> & {
182 + history_24h: SimpleSeries;
183 + baseline_7d: { median: number; p90: number };
184 + incidents: Incident[];
185 + top_asns: { asn: number; name: string; pressure: number }[];
186 + top_services: { slug: string; name: string; pressure: number; observed_availability_24h: number }[];
187 + probes: Probe[];
188 + matrix: LatencyPair[];
189 +};
190 +
191 +export interface Country {
192 + cc: string;
193 + name: string;
194 + region: string;
195 + lat: number;
196 + lon: number;
197 + pressure: number;
198 + level: LevelId;
199 + level_label: string;
200 + delta_1h: number;
201 + trend: Trend;
202 + components: ComponentScores;
203 + probes: number;
204 + targets: number;
205 + role: 'probe' | 'target' | 'both';
206 + coverage_ok: boolean;
207 +}
208 +export type CountryDetailResponse = Omit<Country, 'probes' | 'targets'> & {
209 + history_24h: SimpleSeries;
210 + baseline_7d: { median: number; p90: number };
211 + incidents: Incident[];
212 + asns: { asn: number; name: string; pressure: number }[];
213 + services: { slug: string; name: string; pressure: number; observed_availability_24h: number }[];
214 + probes: Probe[];
215 + targets: CountryTargetRow[];
216 +};
217 +
218 +export interface AsnRow {
219 + asn: number;
220 + name: string;
221 + country: string;
222 + pressure: number;
223 + level: LevelId;
224 + routing: number;
225 + latency: number;
226 + availability: number;
227 + targets: number;
228 + prefixes_observed: number;
229 + importance: number;
230 +}
231 +export interface AsnDetail {
232 + asn: number;
233 + name: string;
234 + country: string;
235 + importance: number;
236 + ts: string;
237 + pressure: number;
238 + level: LevelId;
239 + level_label: string;
240 + delta_1h: number;
241 + trend: Trend;
242 + confidence: number;
243 + components: ComponentScores;
244 + bgp: {
245 + prefixes_observed_24h: number;
246 + announcements_1h: number;
247 + withdrawals_1h: number;
248 + churn_ratio: number;
249 + origin_changes_1h: number;
250 + path_stability: number;
251 + series_24h: { ts: string; announcements: number; withdrawals: number }[];
252 + };
253 + regions_observed: string[];
254 + targets: TargetRow[];
255 + history_24h: SimpleSeries;
256 + incidents: Incident[];
257 +}
258 +
259 +export interface VendorStatus {
260 + indicator: string;
261 + incidents: number;
262 + source: string;
263 + checked_at: string;
264 + titles?: string[];
265 + url?: string;
266 +}
267 +export interface ServiceRow {
268 + slug: string;
269 + name: string;
270 + category: string;
271 + pressure: number;
272 + level: LevelId;
273 + observed_availability_24h: number;
274 + targets: number;
275 + affected_regions: string[];
276 + vendor_status: VendorStatus | null;
277 +}
278 +export interface MatrixCell {
279 + target_id: string;
280 + ok: boolean;
281 + ttfb_ms: number;
282 + z: number;
283 + ts: string;
284 +}
285 +export interface ServiceDetail extends Omit<ServiceRow, 'affected_regions' | 'targets'> {
286 + observed: {
287 + availability_24h: number;
288 + availability_1h: number;
289 + ttfb_ms_median_1h: number;
290 + ttfb_ms_baseline: number;
291 + tls_ms_median_1h: number;
292 + failures_1h: number;
293 + };
294 + affected_regions: { id: string; name: string; observation: string }[];
295 + discrepancy: string | null;
296 + matrix: { probe_id: string; probe_region: string; targets: MatrixCell[] }[];
297 + targets: TargetRow[];
298 + history_24h: SimpleSeries;
299 + incidents: Incident[];
300 +}
301 +
302 +export interface Target {
303 + target_id: string;
304 + name: string;
305 + hostname: string;
306 + category: string;
307 + provider: string;
308 + service_id: string;
309 + country: string;
310 + region: string;
311 + importance: number;
312 + tier: number;
313 + pressure: number;
314 + ok_ratio_1h: number;
315 + ttfb_ms_median_1h: number;
316 +}
317 +export interface TargetDetail extends Target {
318 + latest_by_probe: {
319 + probe_id: string;
320 + kind: string;
321 + ts: string;
322 + ok: boolean;
323 + error: string | null;
324 + dns_ms: number | null;
325 + tcp_ms: number | null;
326 + tls_ms: number | null;
327 + ttfb_ms: number | null;
328 + http_status: number | null;
329 + resolved_ip: string | null;
330 + packet_loss: number | null;
331 + rtt_avg_ms: number | null;
332 + z: number;
333 + }[];
334 + series_24h: { step_seconds: number; points: { ts: string; ttfb_ms_p50: number; ok_ratio: number }[] };
335 + dns: { resolvers: { resolver: string; rcode: string; answers: number; ms: number }[]; disagreement: boolean };
336 +}
337 +
338 +export interface Hypothesis {
339 + text: string;
340 + confidence: number;
341 + evidence: string[];
342 +}
343 +export interface Incident {
344 + event_id: string;
345 + slug: string;
346 + type: IncidentType;
347 + title: string;
348 + summary: string;
349 + status: IncidentStatus;
350 + scope_type: string;
351 + scope_id: string | null;
352 + scope_label: string;
353 + started_at: string;
354 + updated_at: string;
355 + ended_at: string | null;
356 + duration_s: number;
357 + peak_pressure: number;
358 + current_pressure: number;
359 + confidence: number;
360 + affected_probes: number;
361 + affected_targets: number;
362 + affected_asns: number[];
363 + affected_services: string[];
364 + hypotheses: Hypothesis[];
365 +}
366 +export interface IncidentDetail extends Incident {
367 + timeline: { ts: string; status: IncidentStatus; pressure: number; note: string }[];
368 + evidence: {
369 + signal_id: string;
370 + label: string;
371 + scope_type: string;
372 + scope_id: string | null;
373 + current: number;
374 + baseline: number;
375 + robust_z: number;
376 + samples: number;
377 + ts: string;
378 + }[];
379 + series: { step_seconds: number; points: { ts: string; pressure: number; global_pressure: number }[] };
380 + probes: { probe_id: string; region: string; observation: string }[];
381 + targets: { target_id: string; name: string; service_id: string; observation: string }[];
382 + bgp: { withdrawals_ratio: number; announcements_ratio: number; origin_changes: number } | null;
383 + annotations: { ts: string; author: string; text: string }[];
384 +}
385 +export interface IncidentList {
386 + total: number;
387 + incidents: Incident[];
388 +}
389 +
390 +export interface Front {
391 + id: string;
392 + name: string;
393 + status: string;
394 + intensity: number;
395 + confidence: number;
396 + direction: string;
397 + since: string;
398 + from: { region: string; name: string; lat: number; lon: number };
399 + to: { region: string; name: string; lat: number; lon: number };
400 + observed: { latency_pct: number; churn_x: number; loss_pct: number; pairs: number; targets: number; route_changes: number };
401 +}
402 +
403 +export interface BgpCollector {
404 + id: string;
405 + location: string;
406 + announcements_per_s: number;
407 + withdrawals_per_s: number;
408 + peers: number;
409 + last_message: string;
410 + fresh: boolean;
411 +}
412 +export interface BgpStats {
413 + ts: string;
414 + fresh: boolean;
415 + updates_per_s: number;
416 + announcements_per_s: number;
417 + withdrawals_per_s: number;
418 + baseline: { announcements_per_s: number; withdrawals_per_s: number };
419 + ratio: { announcements: number; withdrawals: number };
420 + unique_prefixes_1m: number;
421 + unique_origins_1m: number;
422 + origin_changes_1m: number;
423 + peers: number;
424 + collectors: BgpCollector[];
425 + series_1h: { ts: string; announcements: number; withdrawals: number }[];
426 + top_origins_1h: { asn: number; name: string; announcements: number; withdrawals: number }[];
427 +}
428 +export type BgpStatsEvent = Pick<BgpStats, 'ts' | 'updates_per_s' | 'announcements_per_s' | 'withdrawals_per_s' | 'ratio' | 'fresh'>;
429 +
430 +export interface Ticker {
431 + ts: string;
432 + bgp_updates_per_s: number;
433 + bgp_withdrawals_per_s: number;
434 + bgp_updates_per_min: number;
435 + probes_active: number;
436 + probes_total: number;
437 + measurements_per_s: number;
438 + measurements_per_min: number;
439 + targets_degraded: number;
440 + targets_total: number;
441 + regions_elevated: number;
442 + regions_normal: number;
443 + regions_severe: number;
444 + dns_failures_per_min: number;
445 + median_global_rtt_ms: number;
446 + route_changes_per_min: number;
447 + active_incidents: number;
448 + internal_status: InternalStatus;
449 +}
450 +
451 +export interface Hop {
452 + n: number;
453 + ip: string;
454 + asn: number | null;
455 + asn_name: string | null;
456 + rtt_ms: number | null;
457 + private: boolean;
458 +}
459 +export interface RouteResponse {
460 + probe: { probe_id: string; name: string; asn: number };
461 + target: { target_id: string; name: string; hostname: string; asn: number };
462 + current: { ts: string; route_hash: string; reached: boolean; total_ms: number; hops: Hop[] };
463 + baseline: { route_hash: string; share_7d: number; first_seen: string; last_seen: string; hops: Hop[] };
464 + diff: {
465 + changed: boolean;
466 + added: { n: number; ip: string; asn: number | null }[];
467 + removed: { n: number; ip: string; asn: number | null }[];
468 + asn_path_current: number[];
469 + asn_path_baseline: number[];
470 + latency_shift_ms: number;
471 + hop_delta: number;
472 + };
473 + history_24h: { ts: string; route_hash: string; hop_count: number; total_ms: number }[];
474 + route_share_7d: { route_hash: string; share: number; asn_path: number[] }[];
475 +}
476 +export interface RoutePair {
477 + probe_id: string;
478 + target_id: string;
479 + changed_24h: number;
480 + current_route_hash: string;
481 + stable: boolean;
482 +}
483 +
484 +export interface HistoryDay {
485 + date: string;
486 + min: number;
487 + max: number;
488 + avg: number;
489 + events: number;
490 +}
491 +export interface HistoryMonthRow {
492 + month: string;
493 + min: number;
494 + max: number;
495 + avg: number;
496 + events: number;
497 +}
498 +export interface HistorySummary {
499 + year?: number;
500 + month?: number;
501 + days?: HistoryDay[];
502 + months?: HistoryMonthRow[];
503 + top_events: Incident[];
504 + top_asns: { asn: number; name: string; events: number; max_pressure: number }[];
505 + top_regions: { id: string; name: string; events: number; max_pressure: number; hours_elevated: number }[];
506 + largest: { pressure: Incident | null; routing: Incident | null; dns: Incident | null; latency: Incident | null };
507 + available_months: string[];
508 +}
509 +
510 +export interface ExplainSignal {
511 + signal_id: string;
512 + label: string;
513 + scope_type: string;
514 + scope_id: string | null;
515 + current: number;
516 + baseline_median: number;
517 + mad: number;
518 + robust_z: number;
519 + samples: number;
520 + stress: number;
521 + contribution: number;
522 +}
523 +export interface Explain {
524 + ts: string;
525 + pressure: number;
526 + components: { id: ComponentId; score: number; weight: number; contribution: number; signals: ExplainSignal[] }[];
527 + excluded_probes: string[];
528 + notes: string[];
529 +}
530 +
531 +export interface Methodology {
532 + weights: Record<string, number>;
533 + levels: { max: number; id: LevelId; label: string }[];
534 + engine: Record<string, number | string>;
535 + components?: Record<string, { signals: { id: string; label: string; weight: number }[] }>;
536 + events?: Record<string, number>;
537 + fronts?: Record<string, number>;
538 + version: number;
539 + updated_at: string;
540 +}
541 +
542 +export interface SearchResult {
543 + type: 'country' | 'asn' | 'service' | 'region' | 'target' | 'incident';
544 + id: string;
545 + label: string;
546 + href: string;
547 + pressure: number | null;
548 +}
549 +
550 +// ---- SSE payloads
551 +export interface Snapshot {
552 + global: GlobalPressure;
553 + ticker: Ticker;
554 + regions: Region[];
555 + fronts: Front[];
556 + incidents: Incident[];
557 +}
558 +export interface RegionalUpdate {
559 + ts: string;
560 + regions: Region[];
561 + countries: { cc: string; pressure: number; level: LevelId; delta_1h: number }[];
562 +}
563 +export interface ProbeStats {
564 + ts: string;
565 + probes_active: number;
566 + probes_total: number;
567 + measurements_per_s: number;
568 + excluded: string[];
569 +}
570 +export interface InternalStatusEvent {
571 + ts: string;
572 + internal_status: InternalStatus;
573 + reason: string;
574 +}
575 +export interface ServiceDegradationEvent {
576 + ts: string;
577 + slug: string;
578 + name: string;
579 + pressure: number;
580 + regions: string[];
581 + observation: string;
582 +}
583 +
584 +// ---- admin
585 +export interface AdminProbe extends Probe {
586 + enabled?: boolean;
587 + health?: {
588 + uptime_24h: number;
589 + clock_offset_ms: number;
590 + missing_ratio_1h: number;
591 + error_rate_1h: number;
592 + buffered: number;
593 + spool_bytes: number;
594 + version: string;
595 + last_health: string;
596 + };
597 + key?: string;
598 +}
599 +export interface AdminOverview {
600 + probes: AdminProbe[];
601 + ingest: { batches_per_min: number; measurements_per_min: number; rejected_per_min: number; last_batch: string };
602 + stores: {
603 + clickhouse: { ok: boolean; inserts_per_s: number; tables: { name: string; rows: number; bytes: number; oldest: string; newest: string }[] };
604 + postgres: { ok: boolean; size_bytes: number };
605 + redis: { ok: boolean; used_memory_bytes: number; keys: number };
606 + };
607 + bgp: { collectors: BgpCollector[]; messages_per_s: number; fresh: boolean; reconnects_24h: number };
608 + engine: { last_run: string; cycle_ms_p50: number; cycle_ms_max: number; runs_1h: number; errors_1h: number; internal_status: InternalStatus; excluded_probes: string[] };
609 + corroboration: { id: string; name: string; ok: boolean; last_fetch: string; incidents: number }[];
610 +}
611 +export interface AdminTarget extends Target {
612 + enabled?: boolean;
613 +}
614 +export interface AdminBaselines {
615 + signal_id: string;
616 + points: { ts: string; value: number; median: number; mad: number; z: number }[];
617 + samples: number;
618 + baseline_days: number;
619 +}
620 +export interface AdminRaw {
621 + columns: string[];
622 + rows: unknown[][];
623 +}
624 +export interface AdminIncident extends Incident {
625 + review?: 'confirmed' | 'dismissed' | 'unreviewed';
626 + note?: string | null;
627 +}
628 +export interface AdminAnnotation {
629 + id?: string;
630 + ts: string;
631 + author?: string;
632 + scope_type: string;
633 + scope_id: string | null;
634 + text: string;
635 +}
636 +export interface AdminReplay {
637 + step_seconds: number;
638 + points: { ts: string; pressure_original: number; pressure_replayed: number }[];
639 +}
640 +export interface AdminConfig {
641 + version: number;
642 + pressure_weights: Record<string, number>;
643 + levels: { max: number; id: string; label: string }[];
644 + engine: Record<string, number | string>;
645 + [key: string]: unknown;
646 +}
added apps/web/tsconfig.json +22 −0
@@ -0,0 +1,22 @@
1 +{
2 + "compilerOptions": {
3 + "target": "ES2022",
4 + "lib": ["dom", "dom.iterable", "esnext"],
5 + "allowJs": true,
6 + "skipLibCheck": true,
7 + "strict": true,
8 + "noUncheckedIndexedAccess": true,
9 + "noEmit": true,
10 + "esModuleInterop": true,
11 + "module": "esnext",
12 + "moduleResolution": "bundler",
13 + "resolveJsonModule": true,
14 + "isolatedModules": true,
15 + "jsx": "react-jsx",
16 + "incremental": true,
17 + "plugins": [{ "name": "next" }],
18 + "paths": { "@/*": ["./src/*"] }
19 + },
20 + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
21 + "exclude": ["node_modules", "qa", "mock"]
22 +}
added data/regions.yaml +91 −0
@@ -0,0 +1,91 @@
1 +# Region model. `probe_regions` are where OUR probes sit (sources); `target_regions` are where destinations are
2 +# anchored. Both use the same id space so a Pressure Front can be expressed as source → destination.
3 +# Centroids are approximate and only used for map placement / arc drawing.
4 +
5 +regions:
6 + na-east: { name: "North America East", continent: "North America", lat: 43.0, lon: -76.0 }
7 + na-central: { name: "North America Central", continent: "North America", lat: 41.0, lon: -95.0 }
8 + na-west: { name: "North America West", continent: "North America", lat: 40.0, lon: -120.0 }
9 + latam: { name: "Latin America", continent: "South America", lat: -15.0, lon: -55.0 }
10 + eu-west: { name: "Western Europe", continent: "Europe", lat: 49.0, lon: 3.0 }
11 + eu-north: { name: "Northern Europe", continent: "Europe", lat: 60.0, lon: 18.0 }
12 + eu-east: { name: "Eastern Europe", continent: "Europe", lat: 50.0, lon: 22.0 }
13 + eu-east-med: { name: "Eastern Mediterranean", continent: "Europe", lat: 38.0, lon: 30.0 }
14 + mena: { name: "Middle East & North Africa", continent: "Asia", lat: 26.0, lon: 45.0 }
15 + africa: { name: "Sub-Saharan Africa", continent: "Africa", lat: -5.0, lon: 22.0 }
16 + asia-south: { name: "South Asia", continent: "Asia", lat: 22.0, lon: 78.0 }
17 + asia-se: { name: "Southeast Asia", continent: "Asia", lat: 5.0, lon: 108.0 }
18 + asia-east: { name: "East Asia", continent: "Asia", lat: 35.0, lon: 125.0 }
19 + oceania: { name: "Oceania", continent: "Oceania", lat: -30.0, lon: 140.0 }
20 + global: { name: "Global / anycast", continent: "Global", lat: 0.0, lon: 0.0 }
21 +
22 +# Country → region (ISO-3166 alpha-2). Countries missing here default to their continent via the fallback below.
23 +country_regions:
24 + CA: na-east
25 + US: na-east # refined by `us_states` when a probe/target carries a state
26 + MX: latam
27 + BR: latam
28 + AR: latam
29 + CL: latam
30 + CO: latam
31 + PE: latam
32 + GB: eu-west
33 + IE: eu-west
34 + FR: eu-west
35 + DE: eu-west
36 + NL: eu-west
37 + BE: eu-west
38 + LU: eu-west
39 + CH: eu-west
40 + AT: eu-west
41 + ES: eu-west
42 + PT: eu-west
43 + IT: eu-west
44 + SE: eu-north
45 + "NO": eu-north
46 + DK: eu-north
47 + FI: eu-north
48 + IS: eu-north
49 + EE: eu-north
50 + LV: eu-north
51 + LT: eu-north
52 + PL: eu-east
53 + CZ: eu-east
54 + SK: eu-east
55 + HU: eu-east
56 + RO: eu-east
57 + BG: eu-east
58 + UA: eu-east
59 + GR: eu-east-med
60 + TR: eu-east-med
61 + CY: eu-east-med
62 + IL: mena
63 + AE: mena
64 + SA: mena
65 + QA: mena
66 + EG: mena
67 + MA: mena
68 + ZA: africa
69 + NG: africa
70 + KE: africa
71 + IN: asia-south
72 + PK: asia-south
73 + BD: asia-south
74 + SG: asia-se
75 + ID: asia-se
76 + MY: asia-se
77 + TH: asia-se
78 + VN: asia-se
79 + PH: asia-se
80 + JP: asia-east
81 + KR: asia-east
82 + CN: asia-east
83 + HK: asia-east
84 + TW: asia-east
85 + AU: oceania
86 + NZ: oceania
87 +
88 +us_states:
89 + west: [CA, OR, WA, NV, AZ, UT, CO, ID, MT, WY, NM, AK, HI]
90 + central: [TX, OK, KS, NE, SD, ND, MN, IA, MO, AR, LA, WI, IL, IN, MI, OH, KY, TN, MS, AL]
91 + east: [NY, NJ, PA, MA, CT, RI, VT, NH, ME, MD, DE, VA, WV, NC, SC, GA, FL, DC]
added data/seed/probes.yaml +11 −0
@@ -0,0 +1,11 @@
1 +# InternetPressure Observability Network — initial probes (keys are generated by `ip probe-key <id>` and never stored here).
2 +# lat/lon are city-level approximations only.
3 +probes:
4 + - { probe_id: ca-qc-01, name: "Québec City (Bell)", region: na-east, country: CA, city: "Québec", provider: "Bell Canada", asn: 577, lat: 46.81, lon: -71.21, node: M4M36 }
5 + - { probe_id: ca-mtl-01, name: "Montréal (OVHcloud)", region: na-east, country: CA, city: "Montréal", provider: "OVHcloud", asn: 16276, lat: 45.31, lon: -73.87, node: BHS128 }
6 + - { probe_id: us-atl-01, name: "Atlanta (MacStadium)", region: na-east, country: US, city: "Atlanta", provider: "MacStadium", asn: 395336, lat: 33.75, lon: -84.39, node: m2m16b }
7 + - { probe_id: fr-gra-01, name: "Gravelines (OVHcloud)", region: eu-west, country: FR, city: "Gravelines", provider: "OVHcloud", asn: 16276, lat: 50.99, lon: 2.13, node: R9128 }
8 + - { probe_id: ie-dub-01, name: "Dublin (MacStadium)", region: eu-west, country: IE, city: "Dublin", provider: "MacStadium", asn: 30377, lat: 53.33, lon: -6.25, node: m1m16 }
9 + - { probe_id: tr-ist-01, name: "Istanbul (Erlion)", region: eu-east-med, country: TR, city: "Istanbul", provider: "Erlion Bilisim", asn: 199099, lat: 41.01, lon: 28.95, node: m4mh }
10 + - { probe_id: tr-usk-01, name: "Uşak (Erlion)", region: eu-east-med, country: TR, city: "Uşak", provider: "Erlion Bilisim", asn: 199099, lat: 38.67, lon: 29.41, node: m4mi }
11 + - { probe_id: cy-ayn-01, name: "Ayia Napa (Cyta)", region: eu-east-med, country: CY, city: "Ayia Napa", provider: "Cyta", asn: 6866, lat: 34.98, lon: 34.00, node: m4mg }
added data/seed/services.yaml +49 −0
@@ -0,0 +1,49 @@
1 +# Services (provider pages `/service/<slug>`). `asns` are the provider's main origin ASNs (for BGP attribution),
2 +# `status` describes the optional public status page connector (corroboration only — never a scoring dependency).
3 +services:
4 + - { slug: cloudflare, name: Cloudflare, category: cdn, asns: [13335], importance: 5, status: { kind: statuspage, url: "https://www.cloudflarestatus.com/api/v2/summary.json" } }
5 + - { slug: akamai, name: Akamai, category: cdn, asns: [20940, 16625, 63949], importance: 5, status: null }
6 + - { slug: fastly, name: Fastly, category: cdn, asns: [54113], importance: 5, status: null }
7 + - { slug: aws, name: Amazon Web Services, category: cloud, asns: [16509, 14618], importance: 5, status: { kind: aws_rss, url: "https://status.aws.amazon.com/rss/all.rss" } }
8 + - { slug: gcp, name: Google Cloud, category: cloud, asns: [15169, 396982], importance: 5, status: { kind: gcp_json, url: "https://status.cloud.google.com/incidents.json" } }
9 + - { slug: google, name: Google, category: search, asns: [15169], importance: 5, status: null }
10 + - { slug: azure, name: Microsoft Azure, category: cloud, asns: [8075], importance: 5, status: { kind: azure_rss, url: "https://azurestatuscdn.azureedge.net/en-us/status/feed/" } }
11 + - { slug: microsoft, name: Microsoft, category: infrastructure, asns: [8075, 8068], importance: 5, status: null }
12 + - { slug: meta, name: Meta, category: social, asns: [32934], importance: 5, status: null }
13 + - { slug: apple, name: Apple, category: infrastructure, asns: [714, 6185], importance: 5, status: null }
14 + - { slug: github, name: GitHub, category: developer, asns: [36459], importance: 5, status: { kind: statuspage, url: "https://www.githubstatus.com/api/v2/summary.json" } }
15 + - { slug: gitlab, name: GitLab, category: developer, asns: [], importance: 3, status: null }
16 + - { slug: npm, name: npm, category: developer, asns: [], importance: 4, status: { kind: statuspage, url: "https://status.npmjs.org/api/v2/summary.json" } }
17 + - { slug: pypi, name: PyPI, category: developer, asns: [], importance: 4, status: { kind: statuspage, url: "https://status.python.org/api/v2/summary.json" } }
18 + - { slug: docker, name: Docker Hub, category: developer, asns: [], importance: 4, status: { kind: statuspage, url: "https://www.dockerstatus.com/api/v2/summary.json" } }
19 + - { slug: ovh, name: OVHcloud, category: cloud, asns: [16276], importance: 4, status: null }
20 + - { slug: hetzner, name: Hetzner, category: cloud, asns: [24940], importance: 4, status: null }
21 + - { slug: digitalocean, name: DigitalOcean, category: cloud, asns: [14061], importance: 4, status: { kind: statuspage, url: "https://status.digitalocean.com/api/v2/summary.json" } }
22 + - { slug: oracle, name: Oracle Cloud, category: cloud, asns: [31898], importance: 3, status: null }
23 + - { slug: ibm, name: IBM Cloud, category: cloud, asns: [36351], importance: 3, status: null }
24 + - { slug: alibaba, name: Alibaba, category: cloud, asns: [45102, 37963], importance: 4, status: null }
25 + - { slug: vercel, name: Vercel, category: developer, asns: [], importance: 3, status: { kind: statuspage, url: "https://www.vercel-status.com/api/v2/summary.json" } }
26 + - { slug: netlify, name: Netlify, category: developer, asns: [], importance: 3, status: { kind: statuspage, url: "https://www.netlifystatus.com/api/v2/summary.json" } }
27 + - { slug: heroku, name: Heroku, category: developer, asns: [], importance: 3, status: { kind: statuspage, url: "https://status.heroku.com/api/v4/current-status" } }
28 + - { slug: atlassian, name: Atlassian, category: developer, asns: [], importance: 3, status: { kind: statuspage, url: "https://status.atlassian.com/api/v2/summary.json" } }
29 + - { slug: letsencrypt, name: "Let's Encrypt", category: infrastructure, asns: [], importance: 5, status: { kind: statusio, url: "https://letsencrypt.status.io/1.0/status/55957a99e800baa4470002da" } }
30 + - { slug: wikimedia, name: Wikimedia, category: search, asns: [14907], importance: 4, status: null }
31 + - { slug: telegram, name: Telegram, category: messaging, asns: [62041, 62014, 59930], importance: 4, status: null }
32 + - { slug: discord, name: Discord, category: messaging, asns: [], importance: 4, status: { kind: statuspage, url: "https://discordstatus.com/api/v2/summary.json" } }
33 + - { slug: slack, name: Slack, category: messaging, asns: [], importance: 4, status: { kind: slack_json, url: "https://slack-status.com/api/v2.0.0/current" } }
34 + - { slug: zoom, name: Zoom, category: messaging, asns: [30103], importance: 4, status: { kind: statuspage, url: "https://status.zoom.us/api/v2/summary.json" } }
35 + - { slug: twilio, name: Twilio, category: messaging, asns: [], importance: 3, status: { kind: statuspage, url: "https://status.twilio.com/api/v2/summary.json" } }
36 + - { slug: x, name: X, category: social, asns: [13414], importance: 4, status: null }
37 + - { slug: tiktok, name: TikTok, category: social, asns: [138699], importance: 4, status: null }
38 + - { slug: reddit, name: Reddit, category: social, asns: [54113], importance: 4, status: { kind: statuspage, url: "https://www.redditstatus.com/api/v2/summary.json" } }
39 + - { slug: netflix, name: Netflix, category: streaming, asns: [2906, 40027], importance: 5, status: null }
40 + - { slug: spotify, name: Spotify, category: streaming, asns: [8403], importance: 4, status: null }
41 + - { slug: shopify, name: Shopify, category: commerce, asns: [], importance: 5, status: { kind: statuspage, url: "https://www.shopifystatus.com/api/v2/summary.json" } }
42 + - { slug: stripe, name: Stripe, category: finance, asns: [], importance: 5, status: null }
43 + - { slug: paypal, name: PayPal, category: finance, asns: [17012], importance: 5, status: null }
44 + - { slug: openai, name: OpenAI, category: ai, asns: [], importance: 4, status: { kind: statuspage, url: "https://status.openai.com/api/v2/summary.json" } }
45 + - { slug: anthropic, name: Anthropic, category: ai, asns: [], importance: 4, status: { kind: statuspage, url: "https://status.anthropic.com/api/v2/summary.json" } }
46 + - { slug: huggingface, name: Hugging Face, category: ai, asns: [], importance: 4, status: null }
47 + - { slug: dropbox, name: Dropbox, category: infrastructure, asns: [19679], importance: 3, status: { kind: statuspage, url: "https://status.dropbox.com/api/v2/summary.json" } }
48 + - { slug: salesforce, name: Salesforce, category: infrastructure, asns: [14340], importance: 4, status: null }
49 + - { slug: okta, name: Okta, category: infrastructure, asns: [], importance: 3, status: null }
added data/targets/targets.yaml +363 −0
@@ -0,0 +1,363 @@
1 +# InternetPressure target registry seed (loaded into Postgres by `ip seed`; editable afterwards in /admin).
2 +# Fields: id, name, host (hostname), url (default https://<host>/), ip (optional fixed IP), cat (category), provider,
3 +# svc (service slug, optional), cc (anchor country or null for global/anycast), imp (importance 1–5), tier (1–3),
4 +# checks (default [http, dns, ping]), tr (traceroute, default false).
5 +# Probing is deliberately light: tier 1 every ~20 s, tier 2 ~45 s, tier 3 ~3 min, DNS every 60 s per resolver,
6 +# ping every 30 s, traceroute every 15 min for `tr: true` targets only.
7 +
8 +defaults: { checks: [http, dns, ping], tr: false, tier: 2, imp: 3 }
9 +
10 +targets:
11 + # ───────── Internet infrastructure: DNS roots (ping + direct DNS only, no HTTP), TLDs, resolvers
12 + - { id: root-a, name: "Root server A (Verisign)", host: a.root-servers.net, ip: 198.41.0.4, cat: dns, provider: verisign, cc: null, imp: 5, tier: 1, checks: [ping], tr: true }
13 + - { id: root-b, name: "Root server B (USC-ISI)", host: b.root-servers.net, ip: 170.247.170.2, cat: dns, provider: isi, cc: null, imp: 5, tier: 1, checks: [ping] }
14 + - { id: root-c, name: "Root server C (Cogent)", host: c.root-servers.net, ip: 192.33.4.12, cat: dns, provider: cogent, cc: null, imp: 5, tier: 1, checks: [ping] }
15 + - { id: root-d, name: "Root server D (UMD)", host: d.root-servers.net, ip: 199.7.91.13, cat: dns, provider: umd, cc: null, imp: 5, tier: 1, checks: [ping] }
16 + - { id: root-e, name: "Root server E (NASA)", host: e.root-servers.net, ip: 192.203.230.10, cat: dns, provider: nasa, cc: null, imp: 5, tier: 1, checks: [ping] }
17 + - { id: root-f, name: "Root server F (ISC)", host: f.root-servers.net, ip: 192.5.5.241, cat: dns, provider: isc, cc: null, imp: 5, tier: 1, checks: [ping], tr: true }
18 + - { id: root-g, name: "Root server G (US DoD)", host: g.root-servers.net, ip: 192.112.36.4, cat: dns, provider: dod, cc: null, imp: 5, tier: 1, checks: [ping] }
19 + - { id: root-h, name: "Root server H (US Army)", host: h.root-servers.net, ip: 198.97.190.53, cat: dns, provider: army, cc: null, imp: 5, tier: 1, checks: [ping] }
20 + - { id: root-i, name: "Root server I (Netnod)", host: i.root-servers.net, ip: 192.36.148.17, cat: dns, provider: netnod, cc: null, imp: 5, tier: 1, checks: [ping] }
21 + - { id: root-j, name: "Root server J (Verisign)", host: j.root-servers.net, ip: 192.58.128.30, cat: dns, provider: verisign, cc: null, imp: 5, tier: 1, checks: [ping] }
22 + - { id: root-k, name: "Root server K (RIPE NCC)", host: k.root-servers.net, ip: 193.0.14.129, cat: dns, provider: ripe, cc: null, imp: 5, tier: 1, checks: [ping], tr: true }
23 + - { id: root-l, name: "Root server L (ICANN)", host: l.root-servers.net, ip: 199.7.83.42, cat: dns, provider: icann, cc: null, imp: 5, tier: 1, checks: [ping] }
24 + - { id: root-m, name: "Root server M (WIDE)", host: m.root-servers.net, ip: 202.12.27.33, cat: dns, provider: wide, cc: null, imp: 5, tier: 1, checks: [ping] }
25 + - { id: dns-google, name: "Google Public DNS", host: dns.google, ip: 8.8.8.8, cat: dns, provider: google, svc: google, cc: null, imp: 5, tier: 1, checks: [http, ping], tr: true }
26 + - { id: dns-cloudflare, name: "Cloudflare 1.1.1.1", host: one.one.one.one, ip: 1.1.1.1, cat: dns, provider: cloudflare, svc: cloudflare, cc: null, imp: 5, tier: 1, checks: [http, ping], tr: true }
27 + - { id: dns-quad9, name: "Quad9", host: dns.quad9.net, ip: 9.9.9.9, cat: dns, provider: quad9, cc: null, imp: 4, tier: 1, checks: [http, ping] }
28 + - { id: dns-opendns, name: "OpenDNS (Cisco)", host: dns.opendns.com, ip: 208.67.222.222, cat: dns, provider: cisco, cc: null, imp: 4, tier: 1, checks: [ping] }
29 + - { id: dns-verisign-com, name: ".com authoritative (a.gtld-servers.net)", host: a.gtld-servers.net, ip: 192.5.6.30, cat: dns, provider: verisign, cc: null, imp: 5, tier: 1, checks: [ping] }
30 + - { id: dns-afilias-org, name: ".org authoritative (a0.org.afilias-nst.info)", host: a0.org.afilias-nst.info, ip: 199.19.56.1, cat: dns, provider: pir, cc: null, imp: 4, tier: 2, checks: [ping] }
31 + - { id: dns-nic-io, name: ".io authoritative (a0.nic.io)", host: a0.nic.io, cat: dns, provider: identity-digital, cc: null, imp: 3, tier: 2, checks: [ping] }
32 + - { id: dns-cira-ca, name: ".ca authoritative (any.ca-servers.ca)", host: any.ca-servers.ca, cat: dns, provider: cira, cc: CA, imp: 4, tier: 2, checks: [ping] }
33 + - { id: dns-afnic-fr, name: ".fr authoritative (d.nic.fr)", host: d.nic.fr, cat: dns, provider: afnic, cc: FR, imp: 4, tier: 2, checks: [ping] }
34 + - { id: dns-denic-de, name: ".de authoritative (a.nic.de)", host: a.nic.de, cat: dns, provider: denic, cc: DE, imp: 4, tier: 2, checks: [ping] }
35 + - { id: dns-nominet-uk, name: ".uk authoritative (dns1.nic.uk)", host: dns1.nic.uk, cat: dns, provider: nominet, cc: GB, imp: 4, tier: 2, checks: [ping] }
36 + - { id: dns-jprs-jp, name: ".jp authoritative (a.dns.jp)", host: a.dns.jp, cat: dns, provider: jprs, cc: JP, imp: 3, tier: 3, checks: [ping] }
37 + - { id: dns-auda-au, name: ".au authoritative (q.au)", host: q.au, cat: dns, provider: auda, cc: AU, imp: 3, tier: 3, checks: [ping] }
38 +
39 + # ───────── CDNs & edge
40 + - { id: cloudflare-www, name: "Cloudflare", host: www.cloudflare.com, cat: cdn, provider: cloudflare, svc: cloudflare, cc: null, imp: 5, tier: 1, tr: true }
41 + - { id: cloudflare-cdnjs, name: "cdnjs (Cloudflare)", host: cdnjs.cloudflare.com, url: "https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js", cat: cdn, provider: cloudflare, svc: cloudflare, cc: null, imp: 4, tier: 1 }
42 + - { id: cloudflare-workers, name: "Cloudflare Workers", host: workers.dev, url: "https://workers.dev/", cat: cdn, provider: cloudflare, svc: cloudflare, cc: null, imp: 3, tier: 2 }
43 + - { id: akamai-www, name: "Akamai", host: www.akamai.com, cat: cdn, provider: akamai, svc: akamai, cc: null, imp: 5, tier: 1, tr: true }
44 + - { id: fastly-www, name: "Fastly", host: www.fastly.com, cat: cdn, provider: fastly, svc: fastly, cc: null, imp: 5, tier: 1, tr: true }
45 + - { id: fastly-jsdelivr, name: "jsDelivr", host: cdn.jsdelivr.net, url: "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js", cat: cdn, provider: jsdelivr, cc: null, imp: 3, tier: 2 }
46 + - { id: cloudfront, name: "Amazon CloudFront", host: d1.awsstatic.com, url: "https://d1.awsstatic.com/favicon.ico", cat: cdn, provider: aws, svc: aws, cc: null, imp: 5, tier: 1 }
47 + - { id: google-gstatic, name: "Google static (gstatic)", host: www.gstatic.com, url: "https://www.gstatic.com/generate_204", cat: cdn, provider: google, svc: google, cc: null, imp: 5, tier: 1, tr: true }
48 + - { id: bunny-cdn, name: "bunny.net", host: bunny.net, cat: cdn, provider: bunny, cc: null, imp: 2, tier: 3 }
49 + - { id: unpkg, name: "unpkg", host: unpkg.com, url: "https://unpkg.com/react@18/umd/react.production.min.js", cat: cdn, provider: cloudflare, cc: null, imp: 2, tier: 3 }
50 + - { id: edgio-limelight, name: "Edgio", host: www.edg.io, cat: cdn, provider: edgio, cc: null, imp: 2, tier: 3 }
51 + - { id: cdn77, name: "CDN77", host: www.cdn77.com, cat: cdn, provider: cdn77, cc: null, imp: 2, tier: 3 }
52 +
53 + # ───────── Cloud providers (regional endpoints where public)
54 + - { id: aws-console, name: "AWS", host: aws.amazon.com, cat: cloud, provider: aws, svc: aws, cc: US, imp: 5, tier: 1, tr: true }
55 + - { id: aws-us-east-1, name: "AWS us-east-1 (S3)", host: s3.us-east-1.amazonaws.com, url: "https://s3.us-east-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: US, imp: 5, tier: 1, tr: true }
56 + - { id: aws-us-west-2, name: "AWS us-west-2 (S3)", host: s3.us-west-2.amazonaws.com, url: "https://s3.us-west-2.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: US, imp: 4, tier: 1 }
57 + - { id: aws-ca-central-1, name: "AWS ca-central-1 (S3)", host: s3.ca-central-1.amazonaws.com, url: "https://s3.ca-central-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: CA, imp: 4, tier: 1, tr: true }
58 + - { id: aws-eu-west-1, name: "AWS eu-west-1 (S3)", host: s3.eu-west-1.amazonaws.com, url: "https://s3.eu-west-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: IE, imp: 5, tier: 1, tr: true }
59 + - { id: aws-eu-central-1, name: "AWS eu-central-1 (S3)", host: s3.eu-central-1.amazonaws.com, url: "https://s3.eu-central-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: DE, imp: 5, tier: 1 }
60 + - { id: aws-eu-west-3, name: "AWS eu-west-3 Paris (S3)", host: s3.eu-west-3.amazonaws.com, url: "https://s3.eu-west-3.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: FR, imp: 4, tier: 2 }
61 + - { id: aws-ap-southeast-1, name: "AWS ap-southeast-1 (S3)", host: s3.ap-southeast-1.amazonaws.com, url: "https://s3.ap-southeast-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: SG, imp: 4, tier: 2, tr: true }
62 + - { id: aws-ap-northeast-1, name: "AWS ap-northeast-1 (S3)", host: s3.ap-northeast-1.amazonaws.com, url: "https://s3.ap-northeast-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: JP, imp: 4, tier: 2, tr: true }
63 + - { id: aws-ap-southeast-2, name: "AWS ap-southeast-2 (S3)", host: s3.ap-southeast-2.amazonaws.com, url: "https://s3.ap-southeast-2.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: AU, imp: 3, tier: 2, tr: true }
64 + - { id: aws-sa-east-1, name: "AWS sa-east-1 (S3)", host: s3.sa-east-1.amazonaws.com, url: "https://s3.sa-east-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: BR, imp: 3, tier: 2, tr: true }
65 + - { id: aws-ap-south-1, name: "AWS ap-south-1 Mumbai (S3)", host: s3.ap-south-1.amazonaws.com, url: "https://s3.ap-south-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: IN, imp: 3, tier: 2, tr: true }
66 + - { id: aws-af-south-1, name: "AWS af-south-1 Cape Town (S3)", host: s3.af-south-1.amazonaws.com, url: "https://s3.af-south-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: ZA, imp: 3, tier: 3, tr: true }
67 + - { id: aws-me-south-1, name: "AWS me-south-1 Bahrain (S3)", host: s3.me-south-1.amazonaws.com, url: "https://s3.me-south-1.amazonaws.com/", cat: cloud, provider: aws, svc: aws, cc: BH, imp: 2, tier: 3 }
68 + - { id: gcp-www, name: "Google Cloud", host: cloud.google.com, cat: cloud, provider: google, svc: gcp, cc: US, imp: 5, tier: 1 }
69 + - { id: gcp-storage, name: "Google Cloud Storage", host: storage.googleapis.com, url: "https://storage.googleapis.com/", cat: cloud, provider: google, svc: gcp, cc: null, imp: 5, tier: 1, tr: true }
70 + - { id: gcp-apis, name: "Google APIs", host: www.googleapis.com, url: "https://www.googleapis.com/discovery/v1/apis", cat: cloud, provider: google, svc: gcp, cc: null, imp: 5, tier: 1 }
71 + - { id: azure-www, name: "Microsoft Azure", host: azure.microsoft.com, cat: cloud, provider: microsoft, svc: azure, cc: US, imp: 5, tier: 1, tr: true }
72 + - { id: azure-portal, name: "Azure portal", host: portal.azure.com, cat: cloud, provider: microsoft, svc: azure, cc: null, imp: 4, tier: 1 }
73 + - { id: azure-blob-eastus, name: "Azure Blob (East US)", host: azure.microsoft.com, url: "https://azure.status.microsoft/en-us/status", cat: cloud, provider: microsoft, svc: azure, cc: US, imp: 3, tier: 2 }
74 + - { id: ovh-www, name: "OVHcloud", host: www.ovhcloud.com, cat: cloud, provider: ovh, svc: ovh, cc: FR, imp: 4, tier: 1, tr: true }
75 + - { id: ovh-ca, name: "OVHcloud Canada", host: www.ovhcloud.com, url: "https://www.ovhcloud.com/en-ca/", cat: cloud, provider: ovh, svc: ovh, cc: CA, imp: 3, tier: 2 }
76 + - { id: hetzner, name: "Hetzner", host: www.hetzner.com, cat: cloud, provider: hetzner, svc: hetzner, cc: DE, imp: 4, tier: 1, tr: true }
77 + - { id: digitalocean, name: "DigitalOcean", host: www.digitalocean.com, cat: cloud, provider: digitalocean, svc: digitalocean, cc: US, imp: 4, tier: 1 }
78 + - { id: linode, name: "Akamai Linode", host: www.linode.com, cat: cloud, provider: akamai, svc: akamai, cc: US, imp: 3, tier: 2 }
79 + - { id: vultr, name: "Vultr", host: www.vultr.com, cat: cloud, provider: vultr, cc: US, imp: 3, tier: 2 }
80 + - { id: oracle-cloud, name: "Oracle Cloud", host: www.oracle.com, url: "https://www.oracle.com/cloud/", cat: cloud, provider: oracle, svc: oracle, cc: US, imp: 4, tier: 2 }
81 + - { id: ibm-cloud, name: "IBM Cloud", host: cloud.ibm.com, cat: cloud, provider: ibm, svc: ibm, cc: US, imp: 3, tier: 2 }
82 + - { id: alibaba-cloud, name: "Alibaba Cloud", host: www.alibabacloud.com, cat: cloud, provider: alibaba, svc: alibaba, cc: SG, imp: 4, tier: 2, tr: true }
83 + - { id: tencent-cloud, name: "Tencent Cloud", host: www.tencentcloud.com, cat: cloud, provider: tencent, cc: SG, imp: 3, tier: 3 }
84 + - { id: scaleway, name: "Scaleway", host: www.scaleway.com, cat: cloud, provider: scaleway, cc: FR, imp: 3, tier: 2 }
85 + - { id: upcloud, name: "UpCloud", host: upcloud.com, cat: cloud, provider: upcloud, cc: FI, imp: 2, tier: 3 }
86 + - { id: exoscale, name: "Exoscale", host: www.exoscale.com, cat: cloud, provider: exoscale, cc: CH, imp: 2, tier: 3 }
87 + - { id: ionos, name: "IONOS", host: www.ionos.com, cat: cloud, provider: ionos, cc: DE, imp: 2, tier: 3 }
88 +
89 + # ───────── Developer infrastructure
90 + - { id: github, name: "GitHub", host: github.com, cat: developer, provider: microsoft, svc: github, cc: US, imp: 5, tier: 1, tr: true }
91 + - { id: github-api, name: "GitHub API", host: api.github.com, url: "https://api.github.com/", cat: developer, provider: microsoft, svc: github, cc: US, imp: 5, tier: 1 }
92 + - { id: github-raw, name: "GitHub raw content", host: raw.githubusercontent.com, url: "https://raw.githubusercontent.com/github/gitignore/main/README.md", cat: developer, provider: microsoft, svc: github, cc: US, imp: 4, tier: 1 }
93 + - { id: gitlab, name: "GitLab", host: gitlab.com, cat: developer, provider: gitlab, svc: gitlab, cc: US, imp: 4, tier: 1 }
94 + - { id: npm-registry, name: "npm registry", host: registry.npmjs.org, url: "https://registry.npmjs.org/react", cat: developer, provider: microsoft, svc: npm, cc: US, imp: 5, tier: 1 }
95 + - { id: pypi, name: "PyPI", host: pypi.org, url: "https://pypi.org/simple/requests/", cat: developer, provider: psf, svc: pypi, cc: US, imp: 5, tier: 1 }
96 + - { id: pypi-files, name: "PyPI files", host: files.pythonhosted.org, url: "https://files.pythonhosted.org/", cat: developer, provider: fastly, svc: pypi, cc: null, imp: 4, tier: 2 }
97 + - { id: docker-hub, name: "Docker Hub", host: hub.docker.com, cat: developer, provider: docker, svc: docker, cc: US, imp: 5, tier: 1 }
98 + - { id: docker-registry, name: "Docker registry", host: registry-1.docker.io, url: "https://registry-1.docker.io/v2/", cat: developer, provider: docker, svc: docker, cc: US, imp: 5, tier: 1 }
99 + - { id: ghcr, name: "GitHub Container Registry", host: ghcr.io, url: "https://ghcr.io/v2/", cat: developer, provider: microsoft, svc: github, cc: US, imp: 4, tier: 2 }
100 + - { id: crates-io, name: "crates.io", host: crates.io, cat: developer, provider: rust-foundation, cc: US, imp: 3, tier: 2 }
101 + - { id: go-proxy, name: "Go module proxy", host: proxy.golang.org, url: "https://proxy.golang.org/golang.org/x/net/@latest", cat: developer, provider: google, svc: google, cc: null, imp: 4, tier: 2 }
102 + - { id: maven-central, name: "Maven Central", host: repo1.maven.org, url: "https://repo1.maven.org/maven2/", cat: developer, provider: sonatype, cc: US, imp: 4, tier: 2 }
103 + - { id: rubygems, name: "RubyGems", host: rubygems.org, cat: developer, provider: ruby-central, cc: US, imp: 3, tier: 3 }
104 + - { id: packagist, name: "Packagist", host: packagist.org, cat: developer, provider: packagist, cc: FR, imp: 3, tier: 3 }
105 + - { id: nuget, name: "NuGet", host: api.nuget.org, url: "https://api.nuget.org/v3/index.json", cat: developer, provider: microsoft, cc: US, imp: 3, tier: 3 }
106 + - { id: vercel, name: "Vercel", host: vercel.com, cat: developer, provider: vercel, svc: vercel, cc: US, imp: 4, tier: 1 }
107 + - { id: netlify, name: "Netlify", host: www.netlify.com, cat: developer, provider: netlify, svc: netlify, cc: US, imp: 3, tier: 2 }
108 + - { id: heroku, name: "Heroku", host: www.heroku.com, cat: developer, provider: salesforce, svc: heroku, cc: US, imp: 3, tier: 2 }
109 + - { id: stackoverflow, name: "Stack Overflow", host: stackoverflow.com, cat: developer, provider: stack-exchange, cc: US, imp: 3, tier: 2 }
110 + - { id: atlassian, name: "Atlassian", host: www.atlassian.com, cat: developer, provider: atlassian, svc: atlassian, cc: AU, imp: 3, tier: 2 }
111 + - { id: bitbucket, name: "Bitbucket", host: bitbucket.org, cat: developer, provider: atlassian, svc: atlassian, cc: US, imp: 3, tier: 2 }
112 + - { id: letsencrypt, name: "Let's Encrypt ACME", host: acme-v02.api.letsencrypt.org, url: "https://acme-v02.api.letsencrypt.org/directory", cat: infrastructure, provider: isrg, svc: letsencrypt, cc: null, imp: 5, tier: 1 }
113 + - { id: ntp-pool, name: "NTP pool", host: www.ntppool.org, cat: infrastructure, provider: ntp-pool, cc: null, imp: 3, tier: 3 }
114 + - { id: ripe-ncc, name: "RIPE NCC", host: www.ripe.net, cat: infrastructure, provider: ripe, cc: NL, imp: 3, tier: 2 }
115 + - { id: arin, name: "ARIN", host: www.arin.net, cat: infrastructure, provider: arin, cc: US, imp: 3, tier: 3 }
116 + - { id: iana, name: "IANA", host: www.iana.org, cat: infrastructure, provider: icann, cc: US, imp: 4, tier: 2 }
117 + - { id: ietf, name: "IETF", host: www.ietf.org, cat: infrastructure, provider: ietf, cc: US, imp: 2, tier: 3 }
118 + - { id: cira, name: "CIRA", host: www.cira.ca, cat: infrastructure, provider: cira, cc: CA, imp: 3, tier: 3 }
119 + - { id: torix, name: "TorIX", host: www.torix.ca, cat: infrastructure, provider: torix, cc: CA, imp: 3, tier: 3 }
120 + - { id: ams-ix, name: "AMS-IX", host: www.ams-ix.net, cat: infrastructure, provider: ams-ix, cc: NL, imp: 3, tier: 3 }
121 + - { id: de-cix, name: "DE-CIX", host: www.de-cix.net, cat: infrastructure, provider: de-cix, cc: DE, imp: 3, tier: 3 }
122 + - { id: linx, name: "LINX", host: www.linx.net, cat: infrastructure, provider: linx, cc: GB, imp: 3, tier: 3 }
123 + - { id: france-ix, name: "France-IX", host: www.franceix.net, cat: infrastructure, provider: france-ix, cc: FR, imp: 2, tier: 3 }
124 + - { id: equinix, name: "Equinix", host: www.equinix.com, cat: infrastructure, provider: equinix, cc: US, imp: 3, tier: 3 }
125 +
126 + # ───────── Search
127 + - { id: google, name: "Google Search", host: www.google.com, url: "https://www.google.com/generate_204", cat: search, provider: google, svc: google, cc: null, imp: 5, tier: 1, tr: true }
128 + - { id: google-ca, name: "Google Canada", host: www.google.ca, url: "https://www.google.ca/generate_204", cat: search, provider: google, svc: google, cc: CA, imp: 3, tier: 2 }
129 + - { id: bing, name: "Bing", host: www.bing.com, cat: search, provider: microsoft, svc: microsoft, cc: US, imp: 4, tier: 1 }
130 + - { id: duckduckgo, name: "DuckDuckGo", host: duckduckgo.com, cat: search, provider: duckduckgo, cc: US, imp: 3, tier: 2 }
131 + - { id: baidu, name: "Baidu", host: www.baidu.com, cat: search, provider: baidu, cc: CN, imp: 4, tier: 2, tr: true }
132 + - { id: yandex, name: "Yandex", host: ya.ru, cat: search, provider: yandex, cc: RU, imp: 3, tier: 3 }
133 + - { id: naver, name: "Naver", host: www.naver.com, cat: search, provider: naver, cc: KR, imp: 3, tier: 3 }
134 + - { id: yahoo-jp, name: "Yahoo! Japan", host: www.yahoo.co.jp, cat: search, provider: ly-corp, cc: JP, imp: 3, tier: 3 }
135 + - { id: wikipedia, name: "Wikipedia", host: en.wikipedia.org, url: "https://en.wikipedia.org/wiki/Main_Page", cat: search, provider: wikimedia, svc: wikimedia, cc: null, imp: 5, tier: 1, tr: true }
136 + - { id: archive-org, name: "Internet Archive", host: archive.org, cat: search, provider: internet-archive, cc: US, imp: 3, tier: 3 }
137 +
138 + # ───────── Messaging & communication
139 + - { id: whatsapp, name: "WhatsApp", host: web.whatsapp.com, cat: messaging, provider: meta, svc: meta, cc: null, imp: 5, tier: 1 }
140 + - { id: telegram, name: "Telegram", host: web.telegram.org, cat: messaging, provider: telegram, svc: telegram, cc: null, imp: 4, tier: 1 }
141 + - { id: signal, name: "Signal", host: signal.org, cat: messaging, provider: signal, cc: US, imp: 3, tier: 2 }
142 + - { id: discord, name: "Discord", host: discord.com, cat: messaging, provider: discord, svc: discord, cc: US, imp: 4, tier: 1 }
143 + - { id: slack, name: "Slack", host: slack.com, cat: messaging, provider: salesforce, svc: slack, cc: US, imp: 4, tier: 1 }
144 + - { id: zoom, name: "Zoom", host: zoom.us, cat: messaging, provider: zoom, svc: zoom, cc: US, imp: 4, tier: 1 }
145 + - { id: teams, name: "Microsoft Teams", host: teams.microsoft.com, cat: messaging, provider: microsoft, svc: microsoft, cc: null, imp: 4, tier: 1 }
146 + - { id: outlook, name: "Outlook.com", host: outlook.live.com, cat: messaging, provider: microsoft, svc: microsoft, cc: null, imp: 4, tier: 1 }
147 + - { id: gmail, name: "Gmail", host: mail.google.com, cat: messaging, provider: google, svc: google, cc: null, imp: 5, tier: 1 }
148 + - { id: protonmail, name: "Proton Mail", host: mail.proton.me, cat: messaging, provider: proton, cc: CH, imp: 3, tier: 2 }
149 + - { id: webex, name: "Webex", host: www.webex.com, cat: messaging, provider: cisco, cc: US, imp: 3, tier: 3 }
150 + - { id: twilio, name: "Twilio", host: www.twilio.com, cat: messaging, provider: twilio, svc: twilio, cc: US, imp: 3, tier: 2 }
151 + - { id: line, name: "LINE", host: line.me, cat: messaging, provider: ly-corp, cc: JP, imp: 3, tier: 3 }
152 + - { id: kakao, name: "Kakao", host: www.kakaocorp.com, cat: messaging, provider: kakao, cc: KR, imp: 2, tier: 3 }
153 + - { id: wechat, name: "WeChat", host: www.wechat.com, cat: messaging, provider: tencent, cc: CN, imp: 3, tier: 3 }
154 +
155 + # ───────── Social
156 + - { id: facebook, name: "Facebook", host: www.facebook.com, cat: social, provider: meta, svc: meta, cc: null, imp: 5, tier: 1, tr: true }
157 + - { id: instagram, name: "Instagram", host: www.instagram.com, cat: social, provider: meta, svc: meta, cc: null, imp: 5, tier: 1 }
158 + - { id: x-twitter, name: "X", host: x.com, cat: social, provider: x, svc: x, cc: US, imp: 4, tier: 1 }
159 + - { id: tiktok, name: "TikTok", host: www.tiktok.com, cat: social, provider: bytedance, svc: tiktok, cc: null, imp: 4, tier: 1 }
160 + - { id: youtube, name: "YouTube", host: www.youtube.com, url: "https://www.youtube.com/generate_204", cat: social, provider: google, svc: google, cc: null, imp: 5, tier: 1, tr: true }
161 + - { id: reddit, name: "Reddit", host: www.reddit.com, cat: social, provider: reddit, svc: reddit, cc: US, imp: 4, tier: 1 }
162 + - { id: linkedin, name: "LinkedIn", host: www.linkedin.com, cat: social, provider: microsoft, svc: microsoft, cc: US, imp: 4, tier: 1 }
163 + - { id: pinterest, name: "Pinterest", host: www.pinterest.com, cat: social, provider: pinterest, cc: US, imp: 3, tier: 2 }
164 + - { id: snapchat, name: "Snapchat", host: www.snapchat.com, cat: social, provider: snap, cc: US, imp: 3, tier: 2 }
165 + - { id: threads, name: "Threads", host: www.threads.net, cat: social, provider: meta, svc: meta, cc: null, imp: 3, tier: 2 }
166 + - { id: bluesky, name: "Bluesky", host: bsky.app, cat: social, provider: bluesky, cc: US, imp: 2, tier: 3 }
167 + - { id: mastodon-social, name: "mastodon.social", host: mastodon.social, cat: social, provider: mastodon, cc: DE, imp: 2, tier: 3 }
168 + - { id: vk, name: "VK", host: vk.com, cat: social, provider: vk, cc: RU, imp: 3, tier: 3 }
169 + - { id: twitch, name: "Twitch", host: www.twitch.tv, cat: social, provider: amazon, svc: aws, cc: US, imp: 3, tier: 2 }
170 +
171 + # ───────── Streaming & media
172 + - { id: netflix, name: "Netflix", host: www.netflix.com, cat: streaming, provider: netflix, svc: netflix, cc: US, imp: 5, tier: 1, tr: true }
173 + - { id: netflix-fast, name: "Netflix fast.com", host: fast.com, cat: streaming, provider: netflix, svc: netflix, cc: null, imp: 3, tier: 2 }
174 + - { id: spotify, name: "Spotify", host: open.spotify.com, cat: streaming, provider: spotify, svc: spotify, cc: SE, imp: 4, tier: 1 }
175 + - { id: disney-plus, name: "Disney+", host: www.disneyplus.com, cat: streaming, provider: disney, cc: US, imp: 3, tier: 2 }
176 + - { id: prime-video, name: "Prime Video", host: www.primevideo.com, cat: streaming, provider: amazon, svc: aws, cc: US, imp: 3, tier: 2 }
177 + - { id: apple-tv, name: "Apple TV+", host: tv.apple.com, cat: streaming, provider: apple, svc: apple, cc: US, imp: 3, tier: 2 }
178 + - { id: crave, name: "Crave (Bell)", host: www.crave.ca, cat: streaming, provider: bell, cc: CA, imp: 2, tier: 3 }
179 + - { id: bbc-iplayer, name: "BBC iPlayer", host: www.bbc.co.uk, url: "https://www.bbc.co.uk/iplayer", cat: streaming, provider: bbc, cc: GB, imp: 3, tier: 3 }
180 + - { id: soundcloud, name: "SoundCloud", host: soundcloud.com, cat: streaming, provider: soundcloud, cc: DE, imp: 2, tier: 3 }
181 + - { id: vimeo, name: "Vimeo", host: vimeo.com, cat: streaming, provider: vimeo, cc: US, imp: 2, tier: 3 }
182 +
183 + # ───────── Commerce & payments
184 + - { id: amazon, name: "Amazon.com", host: www.amazon.com, cat: commerce, provider: amazon, svc: aws, cc: US, imp: 5, tier: 1 }
185 + - { id: amazon-ca, name: "Amazon.ca", host: www.amazon.ca, cat: commerce, provider: amazon, svc: aws, cc: CA, imp: 3, tier: 2 }
186 + - { id: amazon-de, name: "Amazon.de", host: www.amazon.de, cat: commerce, provider: amazon, svc: aws, cc: DE, imp: 3, tier: 2 }
187 + - { id: amazon-jp, name: "Amazon.co.jp", host: www.amazon.co.jp, cat: commerce, provider: amazon, svc: aws, cc: JP, imp: 3, tier: 3 }
188 + - { id: shopify, name: "Shopify", host: www.shopify.com, cat: commerce, provider: shopify, svc: shopify, cc: CA, imp: 5, tier: 1, tr: true }
189 + - { id: shopify-storefront, name: "Shopify storefronts", host: cdn.shopify.com, url: "https://cdn.shopify.com/shopifycloud/shopify/assets/favicon.ico", cat: commerce, provider: shopify, svc: shopify, cc: null, imp: 4, tier: 1 }
190 + - { id: ebay, name: "eBay", host: www.ebay.com, cat: commerce, provider: ebay, cc: US, imp: 3, tier: 2 }
191 + - { id: alibaba, name: "Alibaba", host: www.alibaba.com, cat: commerce, provider: alibaba, svc: alibaba, cc: CN, imp: 3, tier: 2 }
192 + - { id: aliexpress, name: "AliExpress", host: www.aliexpress.com, cat: commerce, provider: alibaba, svc: alibaba, cc: CN, imp: 3, tier: 3 }
193 + - { id: mercadolibre, name: "Mercado Libre", host: www.mercadolibre.com.br, cat: commerce, provider: mercadolibre, cc: BR, imp: 3, tier: 3, tr: true }
194 + - { id: rakuten, name: "Rakuten", host: www.rakuten.co.jp, cat: commerce, provider: rakuten, cc: JP, imp: 2, tier: 3 }
195 + - { id: walmart, name: "Walmart", host: www.walmart.com, cat: commerce, provider: walmart, cc: US, imp: 3, tier: 2 }
196 + - { id: stripe, name: "Stripe", host: stripe.com, cat: finance, provider: stripe, svc: stripe, cc: US, imp: 5, tier: 1 }
197 + - { id: stripe-api, name: "Stripe API", host: api.stripe.com, url: "https://api.stripe.com/", cat: finance, provider: stripe, svc: stripe, cc: US, imp: 5, tier: 1 }
198 + - { id: paypal, name: "PayPal", host: www.paypal.com, cat: finance, provider: paypal, svc: paypal, cc: US, imp: 5, tier: 1 }
199 + - { id: visa, name: "Visa", host: www.visa.com, cat: finance, provider: visa, cc: US, imp: 4, tier: 2 }
200 + - { id: mastercard, name: "Mastercard", host: www.mastercard.com, cat: finance, provider: mastercard, cc: US, imp: 4, tier: 2 }
201 + - { id: interac, name: "Interac", host: www.interac.ca, cat: finance, provider: interac, cc: CA, imp: 3, tier: 2 }
202 + - { id: adyen, name: "Adyen", host: www.adyen.com, cat: finance, provider: adyen, cc: NL, imp: 3, tier: 3 }
203 + - { id: square, name: "Square", host: squareup.com, cat: finance, provider: block, cc: US, imp: 3, tier: 3 }
204 + - { id: coinbase, name: "Coinbase", host: www.coinbase.com, cat: finance, provider: coinbase, cc: US, imp: 3, tier: 2 }
205 + - { id: binance, name: "Binance", host: www.binance.com, cat: finance, provider: binance, cc: null, imp: 3, tier: 2 }
206 + - { id: nyse, name: "NYSE", host: www.nyse.com, cat: finance, provider: ice, cc: US, imp: 3, tier: 3 }
207 + - { id: nasdaq, name: "Nasdaq", host: www.nasdaq.com, cat: finance, provider: nasdaq, cc: US, imp: 3, tier: 3 }
208 + - { id: tsx, name: "TMX / TSX", host: www.tsx.com, cat: finance, provider: tmx, cc: CA, imp: 3, tier: 3 }
209 + - { id: lse, name: "London Stock Exchange", host: www.londonstockexchange.com, cat: finance, provider: lseg, cc: GB, imp: 3, tier: 3 }
210 + - { id: swift, name: "SWIFT", host: www.swift.com, cat: finance, provider: swift, cc: BE, imp: 3, tier: 3 }
211 + - { id: rbc, name: "RBC", host: www.rbcroyalbank.com, cat: finance, provider: rbc, cc: CA, imp: 3, tier: 3 }
212 + - { id: desjardins, name: "Desjardins", host: www.desjardins.com, cat: finance, provider: desjardins, cc: CA, imp: 3, tier: 3 }
213 + - { id: jpmorgan-chase, name: "Chase", host: www.chase.com, cat: finance, provider: jpmorgan, cc: US, imp: 3, tier: 3 }
214 + - { id: hsbc, name: "HSBC", host: www.hsbc.com, cat: finance, provider: hsbc, cc: GB, imp: 3, tier: 3 }
215 + - { id: bnp, name: "BNP Paribas", host: group.bnpparibas, cat: finance, provider: bnp, cc: FR, imp: 2, tier: 3 }
216 +
217 + # ───────── Government & public services
218 + - { id: canada-ca, name: "Government of Canada", host: www.canada.ca, url: "https://www.canada.ca/en.html", cat: government, provider: gc, cc: CA, imp: 4, tier: 1 }
219 + - { id: quebec-ca, name: "Gouvernement du Québec", host: www.quebec.ca, cat: government, provider: gouv-qc, cc: CA, imp: 3, tier: 2 }
220 + - { id: cra, name: "Canada Revenue Agency", host: www.canada.ca, url: "https://www.canada.ca/en/revenue-agency.html", cat: government, provider: gc, cc: CA, imp: 3, tier: 3 }
221 + - { id: usa-gov, name: "USA.gov", host: www.usa.gov, cat: government, provider: gsa, cc: US, imp: 3, tier: 2 }
222 + - { id: whitehouse, name: "White House", host: www.whitehouse.gov, cat: government, provider: eop, cc: US, imp: 3, tier: 3 }
223 + - { id: irs, name: "IRS", host: www.irs.gov, cat: government, provider: irs, cc: US, imp: 3, tier: 3 }
224 + - { id: nist-time, name: "NIST", host: www.nist.gov, cat: government, provider: nist, cc: US, imp: 2, tier: 3 }
225 + - { id: gov-uk, name: "GOV.UK", host: www.gov.uk, cat: government, provider: gds, cc: GB, imp: 4, tier: 1 }
226 + - { id: service-public-fr, name: "service-public.fr", host: www.service-public.fr, cat: government, provider: dila, cc: FR, imp: 3, tier: 2 }
227 + - { id: bund-de, name: "bund.de", host: www.bund.de, cat: government, provider: bund, cc: DE, imp: 3, tier: 3 }
228 + - { id: europa-eu, name: "European Union", host: european-union.europa.eu, cat: government, provider: eu, cc: BE, imp: 3, tier: 2 }
229 + - { id: gov-au, name: "australia.gov.au", host: www.australia.gov.au, cat: government, provider: gov-au, cc: AU, imp: 3, tier: 3 }
230 + - { id: gov-in, name: "india.gov.in", host: www.india.gov.in, cat: government, provider: nic-in, cc: IN, imp: 3, tier: 3 }
231 + - { id: gov-jp, name: "Japan e-Gov", host: www.e-gov.go.jp, cat: government, provider: gov-jp, cc: JP, imp: 3, tier: 3 }
232 + - { id: gov-br, name: "gov.br", host: www.gov.br, cat: government, provider: gov-br, cc: BR, imp: 3, tier: 3 }
233 + - { id: gov-za, name: "gov.za", host: www.gov.za, cat: government, provider: gov-za, cc: ZA, imp: 2, tier: 3 }
234 + - { id: gov-sg, name: "gov.sg", host: www.gov.sg, cat: government, provider: govtech-sg, cc: SG, imp: 3, tier: 3 }
235 + - { id: gov-kr, name: "korea.kr", host: www.korea.kr, cat: government, provider: gov-kr, cc: KR, imp: 2, tier: 3 }
236 + - { id: gov-mx, name: "gob.mx", host: www.gob.mx, cat: government, provider: gob-mx, cc: MX, imp: 2, tier: 3 }
237 + - { id: gov-nl, name: "Rijksoverheid", host: www.rijksoverheid.nl, cat: government, provider: gov-nl, cc: NL, imp: 2, tier: 3 }
238 + - { id: gov-ie, name: "gov.ie", host: www.gov.ie, cat: government, provider: gov-ie, cc: IE, imp: 2, tier: 3 }
239 + - { id: gov-tr, name: "e-Devlet (Türkiye)", host: www.turkiye.gov.tr, cat: government, provider: gov-tr, cc: TR, imp: 3, tier: 3 }
240 + - { id: gov-cy, name: "gov.cy", host: www.gov.cy, cat: government, provider: gov-cy, cc: CY, imp: 2, tier: 3 }
241 + - { id: gov-pl, name: "gov.pl", host: www.gov.pl, cat: government, provider: gov-pl, cc: PL, imp: 2, tier: 3 }
242 + - { id: gov-il, name: "gov.il", host: www.gov.il, cat: government, provider: gov-il, cc: IL, imp: 2, tier: 3 }
243 + - { id: gov-ae, name: "u.ae", host: u.ae, cat: government, provider: gov-ae, cc: AE, imp: 2, tier: 3 }
244 + - { id: gov-eg, name: "Egypt digital portal", host: digital.gov.eg, cat: government, provider: gov-eg, cc: EG, imp: 2, tier: 3 }
245 + - { id: gov-ng, name: "nigeria.gov.ng", host: nigeria.gov.ng, cat: government, provider: gov-ng, cc: NG, imp: 2, tier: 3 }
246 + - { id: gov-ke, name: "ecitizen.go.ke", host: www.ecitizen.go.ke, cat: government, provider: gov-ke, cc: KE, imp: 2, tier: 3 }
247 + - { id: gov-ar, name: "argentina.gob.ar", host: www.argentina.gob.ar, cat: government, provider: gov-ar, cc: AR, imp: 2, tier: 3 }
248 + - { id: gov-cl, name: "gob.cl", host: www.gob.cl, cat: government, provider: gov-cl, cc: CL, imp: 2, tier: 3 }
249 + - { id: gov-id, name: "indonesia.go.id", host: indonesia.go.id, cat: government, provider: gov-id, cc: ID, imp: 2, tier: 3 }
250 + - { id: gov-nz, name: "govt.nz", host: www.govt.nz, cat: government, provider: gov-nz, cc: NZ, imp: 2, tier: 3 }
251 + - { id: gov-se, name: "government.se", host: www.government.se, cat: government, provider: gov-se, cc: SE, imp: 2, tier: 3 }
252 + - { id: gov-ch, name: "admin.ch", host: www.admin.ch, cat: government, provider: gov-ch, cc: CH, imp: 2, tier: 3 }
253 + - { id: gov-es, name: "administracion.gob.es", host: administracion.gob.es, cat: government, provider: gov-es, cc: ES, imp: 2, tier: 3 }
254 + - { id: gov-it, name: "governo.it", host: www.governo.it, cat: government, provider: gov-it, cc: IT, imp: 2, tier: 3 }
255 + - { id: gov-pt, name: "gov.pt", host: www.gov.pt, cat: government, provider: gov-pt, cc: PT, imp: 2, tier: 3 }
256 + - { id: gov-gr, name: "gov.gr", host: www.gov.gr, cat: government, provider: gov-gr, cc: GR, imp: 2, tier: 3 }
257 + - { id: gov-sa, name: "my.gov.sa", host: www.my.gov.sa, cat: government, provider: gov-sa, cc: SA, imp: 2, tier: 3 }
258 + - { id: gov-hk, name: "gov.hk", host: www.gov.hk, cat: government, provider: gov-hk, cc: HK, imp: 2, tier: 3 }
259 + - { id: gov-tw, name: "gov.tw", host: www.gov.tw, cat: government, provider: gov-tw, cc: TW, imp: 2, tier: 3 }
260 + - { id: gov-ph, name: "gov.ph", host: www.gov.ph, cat: government, provider: gov-ph, cc: PH, imp: 2, tier: 3 }
261 + - { id: gov-vn, name: "chinhphu.vn", host: chinhphu.vn, cat: government, provider: gov-vn, cc: VN, imp: 2, tier: 3 }
262 + - { id: gov-th, name: "thaigov.go.th", host: www.thaigov.go.th, cat: government, provider: gov-th, cc: TH, imp: 2, tier: 3 }
263 + - { id: gov-my, name: "malaysia.gov.my", host: www.malaysia.gov.my, cat: government, provider: gov-my, cc: MY, imp: 2, tier: 3 }
264 + - { id: gov-pk, name: "pakistan.gov.pk", host: www.pakistan.gov.pk, cat: government, provider: gov-pk, cc: PK, imp: 2, tier: 3 }
265 + - { id: gov-ma, name: "maroc.ma", host: www.maroc.ma, cat: government, provider: gov-ma, cc: MA, imp: 2, tier: 3 }
266 + - { id: gov-co, name: "gov.co", host: www.gov.co, cat: government, provider: gov-co, cc: CO, imp: 2, tier: 3 }
267 + - { id: gov-pe, name: "gob.pe", host: www.gob.pe, cat: government, provider: gov-pe, cc: PE, imp: 2, tier: 3 }
268 + - { id: gov-ua, name: "gov.ua", host: www.kmu.gov.ua, cat: government, provider: gov-ua, cc: UA, imp: 2, tier: 3 }
269 + - { id: gov-no, name: "regjeringen.no", host: www.regjeringen.no, cat: government, provider: gov-no, cc: "NO", imp: 2, tier: 3 }
270 + - { id: gov-fi, name: "suomi.fi", host: www.suomi.fi, cat: government, provider: gov-fi, cc: FI, imp: 2, tier: 3 }
271 + - { id: gov-dk, name: "borger.dk", host: www.borger.dk, cat: government, provider: gov-dk, cc: DK, imp: 2, tier: 3 }
272 + - { id: gov-be, name: "belgium.be", host: www.belgium.be, cat: government, provider: gov-be, cc: BE, imp: 2, tier: 3 }
273 + - { id: gov-at, name: "oesterreich.gv.at", host: www.oesterreich.gv.at, cat: government, provider: gov-at, cc: AT, imp: 2, tier: 3 }
274 + - { id: gov-cz, name: "gov.cz", host: www.gov.cz, cat: government, provider: gov-cz, cc: CZ, imp: 2, tier: 3 }
275 + - { id: gov-ro, name: "gov.ro", host: gov.ro, cat: government, provider: gov-ro, cc: RO, imp: 2, tier: 3 }
276 + - { id: gov-hu, name: "kormany.hu", host: kormany.hu, cat: government, provider: gov-hu, cc: HU, imp: 2, tier: 3 }
277 +
278 + # ───────── News
279 + - { id: cbc, name: "CBC/Radio-Canada", host: www.cbc.ca, cat: news, provider: cbc, cc: CA, imp: 3, tier: 2 }
280 + - { id: radio-canada, name: "Radio-Canada", host: ici.radio-canada.ca, cat: news, provider: cbc, cc: CA, imp: 3, tier: 2 }
281 + - { id: la-presse, name: "La Presse", host: www.lapresse.ca, cat: news, provider: la-presse, cc: CA, imp: 2, tier: 3 }
282 + - { id: nytimes, name: "The New York Times", host: www.nytimes.com, cat: news, provider: nyt, cc: US, imp: 3, tier: 2 }
283 + - { id: cnn, name: "CNN", host: www.cnn.com, cat: news, provider: wbd, cc: US, imp: 3, tier: 2 }
284 + - { id: bbc, name: "BBC", host: www.bbc.com, cat: news, provider: bbc, cc: GB, imp: 4, tier: 1 }
285 + - { id: guardian, name: "The Guardian", host: www.theguardian.com, cat: news, provider: gmg, cc: GB, imp: 3, tier: 2 }
286 + - { id: lemonde, name: "Le Monde", host: www.lemonde.fr, cat: news, provider: le-monde, cc: FR, imp: 3, tier: 2 }
287 + - { id: spiegel, name: "Der Spiegel", host: www.spiegel.de, cat: news, provider: spiegel, cc: DE, imp: 3, tier: 3 }
288 + - { id: reuters, name: "Reuters", host: www.reuters.com, cat: news, provider: thomson-reuters, cc: null, imp: 3, tier: 2 }
289 + - { id: aljazeera, name: "Al Jazeera", host: www.aljazeera.com, cat: news, provider: aljazeera, cc: QA, imp: 3, tier: 3 }
290 + - { id: nhk, name: "NHK", host: www3.nhk.or.jp, cat: news, provider: nhk, cc: JP, imp: 3, tier: 3 }
291 + - { id: globo, name: "Globo", host: www.globo.com, cat: news, provider: globo, cc: BR, imp: 3, tier: 3 }
292 + - { id: times-of-india, name: "Times of India", host: timesofindia.indiatimes.com, cat: news, provider: bccl, cc: IN, imp: 3, tier: 3 }
293 + - { id: abc-au, name: "ABC Australia", host: www.abc.net.au, cat: news, provider: abc, cc: AU, imp: 3, tier: 3 }
294 + - { id: hurriyet, name: "Hürriyet", host: www.hurriyet.com.tr, cat: news, provider: demiroren, cc: TR, imp: 2, tier: 3 }
295 + - { id: news24-za, name: "News24", host: www.news24.com, cat: news, provider: media24, cc: ZA, imp: 2, tier: 3 }
296 + - { id: straits-times, name: "The Straits Times", host: www.straitstimes.com, cat: news, provider: sph, cc: SG, imp: 2, tier: 3 }
297 + - { id: el-pais, name: "El País", host: elpais.com, cat: news, provider: prisa, cc: ES, imp: 2, tier: 3 }
298 + - { id: nos-nl, name: "NOS", host: nos.nl, cat: news, provider: npo, cc: NL, imp: 2, tier: 3 }
299 +
300 + # ───────── AI platforms
301 + - { id: openai, name: "OpenAI", host: openai.com, cat: ai, provider: openai, svc: openai, cc: US, imp: 4, tier: 1 }
302 + - { id: openai-api, name: "OpenAI API", host: api.openai.com, url: "https://api.openai.com/v1/models", cat: ai, provider: openai, svc: openai, cc: US, imp: 4, tier: 1 }
303 + - { id: chatgpt, name: "ChatGPT", host: chatgpt.com, cat: ai, provider: openai, svc: openai, cc: US, imp: 4, tier: 1 }
304 + - { id: anthropic, name: "Anthropic", host: www.anthropic.com, cat: ai, provider: anthropic, svc: anthropic, cc: US, imp: 4, tier: 1 }
305 + - { id: anthropic-api, name: "Anthropic API", host: api.anthropic.com, url: "https://api.anthropic.com/v1/models", cat: ai, provider: anthropic, svc: anthropic, cc: US, imp: 4, tier: 1 }
306 + - { id: claude-ai, name: "Claude", host: claude.ai, cat: ai, provider: anthropic, svc: anthropic, cc: US, imp: 3, tier: 2 }
307 + - { id: huggingface, name: "Hugging Face", host: huggingface.co, cat: ai, provider: huggingface, svc: huggingface, cc: US, imp: 4, tier: 1 }
308 + - { id: gemini, name: "Google Gemini", host: gemini.google.com, cat: ai, provider: google, svc: google, cc: null, imp: 3, tier: 2 }
309 + - { id: mistral, name: "Mistral AI", host: mistral.ai, cat: ai, provider: mistral, cc: FR, imp: 3, tier: 2 }
310 + - { id: perplexity, name: "Perplexity", host: www.perplexity.ai, cat: ai, provider: perplexity, cc: US, imp: 2, tier: 3 }
311 + - { id: replicate, name: "Replicate", host: replicate.com, cat: ai, provider: replicate, cc: US, imp: 2, tier: 3 }
312 + - { id: cohere, name: "Cohere", host: cohere.com, cat: ai, provider: cohere, cc: CA, imp: 2, tier: 3 }
313 +
314 + # ───────── Major consumer services / OS vendors
315 + - { id: apple, name: "Apple", host: www.apple.com, cat: infrastructure, provider: apple, svc: apple, cc: US, imp: 5, tier: 1, tr: true }
316 + - { id: apple-icloud, name: "iCloud", host: www.icloud.com, cat: infrastructure, provider: apple, svc: apple, cc: null, imp: 4, tier: 1 }
317 + - { id: apple-captive, name: "Apple captive portal check", host: captive.apple.com, url: "http://captive.apple.com/hotspot-detect.html", cat: infrastructure, provider: apple, svc: apple, cc: null, imp: 3, tier: 1, checks: [http] }
318 + - { id: microsoft, name: "Microsoft", host: www.microsoft.com, cat: infrastructure, provider: microsoft, svc: microsoft, cc: US, imp: 5, tier: 1, tr: true }
319 + - { id: office-365, name: "Microsoft 365", host: www.office.com, cat: infrastructure, provider: microsoft, svc: microsoft, cc: null, imp: 4, tier: 1 }
320 + - { id: windows-update, name: "Windows Update", host: www.msftconnecttest.com, url: "http://www.msftconnecttest.com/connecttest.txt", cat: infrastructure, provider: microsoft, svc: microsoft, cc: null, imp: 3, tier: 2, checks: [http] }
321 + - { id: google-drive, name: "Google Drive", host: drive.google.com, cat: infrastructure, provider: google, svc: google, cc: null, imp: 4, tier: 1 }
322 + - { id: google-maps, name: "Google Maps", host: maps.google.com, cat: infrastructure, provider: google, svc: google, cc: null, imp: 4, tier: 1 }
323 + - { id: dropbox, name: "Dropbox", host: www.dropbox.com, cat: infrastructure, provider: dropbox, svc: dropbox, cc: US, imp: 3, tier: 2 }
324 + - { id: salesforce, name: "Salesforce", host: www.salesforce.com, cat: infrastructure, provider: salesforce, svc: salesforce, cc: US, imp: 4, tier: 2 }
325 + - { id: sap, name: "SAP", host: www.sap.com, cat: infrastructure, provider: sap, cc: DE, imp: 3, tier: 3 }
326 + - { id: adobe, name: "Adobe", host: www.adobe.com, cat: infrastructure, provider: adobe, cc: US, imp: 3, tier: 3 }
327 + - { id: okta, name: "Okta", host: www.okta.com, cat: infrastructure, provider: okta, svc: okta, cc: US, imp: 3, tier: 2 }
328 + - { id: auth0, name: "Auth0", host: auth0.com, cat: infrastructure, provider: okta, svc: okta, cc: US, imp: 3, tier: 3 }
329 + - { id: datadog, name: "Datadog", host: www.datadoghq.com, cat: infrastructure, provider: datadog, cc: US, imp: 3, tier: 3 }
330 + - { id: mozilla, name: "Mozilla", host: www.mozilla.org, cat: infrastructure, provider: mozilla, cc: US, imp: 3, tier: 3 }
331 + - { id: ubuntu-archive, name: "Ubuntu archive", host: archive.ubuntu.com, url: "http://archive.ubuntu.com/ubuntu/dists/noble/Release", cat: infrastructure, provider: canonical, cc: GB, imp: 3, tier: 2, checks: [http, dns, ping] }
332 + - { id: debian, name: "Debian", host: deb.debian.org, url: "http://deb.debian.org/debian/dists/stable/Release", cat: infrastructure, provider: debian, cc: null, imp: 3, tier: 2 }
333 + - { id: steam, name: "Steam", host: store.steampowered.com, cat: infrastructure, provider: valve, cc: US, imp: 3, tier: 2 }
334 + - { id: playstation, name: "PlayStation Network", host: www.playstation.com, cat: infrastructure, provider: sony, cc: US, imp: 3, tier: 3 }
335 + - { id: xbox, name: "Xbox", host: www.xbox.com, cat: infrastructure, provider: microsoft, svc: microsoft, cc: US, imp: 3, tier: 3 }
336 + - { id: uber, name: "Uber", host: www.uber.com, cat: infrastructure, provider: uber, cc: US, imp: 3, tier: 3 }
337 + - { id: airbnb, name: "Airbnb", host: www.airbnb.com, cat: infrastructure, provider: airbnb, cc: US, imp: 2, tier: 3 }
338 + - { id: booking, name: "Booking.com", host: www.booking.com, cat: infrastructure, provider: booking, cc: NL, imp: 3, tier: 3 }
339 +
340 + # ───────── ISPs & telecom (public sites — proxies for provider reachability)
341 + - { id: bell-ca, name: "Bell Canada", host: www.bell.ca, cat: infrastructure, provider: bell, cc: CA, imp: 3, tier: 2 }
342 + - { id: videotron, name: "Vidéotron", host: videotron.com, cat: infrastructure, provider: videotron, cc: CA, imp: 3, tier: 3 }
343 + - { id: rogers, name: "Rogers", host: www.rogers.com, cat: infrastructure, provider: rogers, cc: CA, imp: 3, tier: 3 }
344 + - { id: telus, name: "TELUS", host: www.telus.com, cat: infrastructure, provider: telus, cc: CA, imp: 3, tier: 3 }
345 + - { id: comcast, name: "Comcast Xfinity", host: www.xfinity.com, cat: infrastructure, provider: comcast, cc: US, imp: 3, tier: 3 }
346 + - { id: att, name: "AT&T", host: www.att.com, cat: infrastructure, provider: att, cc: US, imp: 3, tier: 3 }
347 + - { id: verizon, name: "Verizon", host: www.verizon.com, cat: infrastructure, provider: verizon, cc: US, imp: 3, tier: 3 }
348 + - { id: lumen, name: "Lumen", host: www.lumen.com, cat: infrastructure, provider: lumen, cc: US, imp: 3, tier: 3 }
349 + - { id: cogent, name: "Cogent", host: www.cogentco.com, cat: infrastructure, provider: cogent, cc: US, imp: 3, tier: 3 }
350 + - { id: orange-fr, name: "Orange", host: www.orange.fr, cat: infrastructure, provider: orange, cc: FR, imp: 3, tier: 3 }
351 + - { id: deutsche-telekom, name: "Deutsche Telekom", host: www.telekom.de, cat: infrastructure, provider: dt, cc: DE, imp: 3, tier: 3 }
352 + - { id: bt, name: "BT", host: www.bt.com, cat: infrastructure, provider: bt, cc: GB, imp: 3, tier: 3 }
353 + - { id: eir, name: "eir", host: www.eir.ie, cat: infrastructure, provider: eir, cc: IE, imp: 2, tier: 3 }
354 + - { id: turk-telekom, name: "Türk Telekom", host: www.turktelekom.com.tr, cat: infrastructure, provider: turk-telekom, cc: TR, imp: 3, tier: 3 }
355 + - { id: cyta, name: "Cyta", host: www.cyta.com.cy, cat: infrastructure, provider: cyta, cc: CY, imp: 2, tier: 3 }
356 + - { id: ntt, name: "NTT", host: www.ntt.com, cat: infrastructure, provider: ntt, cc: JP, imp: 3, tier: 3 }
357 + - { id: singtel, name: "Singtel", host: www.singtel.com, cat: infrastructure, provider: singtel, cc: SG, imp: 3, tier: 3 }
358 + - { id: telstra, name: "Telstra", host: www.telstra.com.au, cat: infrastructure, provider: telstra, cc: AU, imp: 3, tier: 3 }
359 + - { id: jio, name: "Jio", host: www.jio.com, cat: infrastructure, provider: jio, cc: IN, imp: 3, tier: 3 }
360 + - { id: mtn, name: "MTN", host: www.mtn.com, cat: infrastructure, provider: mtn, cc: ZA, imp: 2, tier: 3 }
361 + - { id: claro-br, name: "Claro Brasil", host: www.claro.com.br, cat: infrastructure, provider: claro, cc: BR, imp: 2, tier: 3 }
362 + - { id: telmex, name: "Telmex", host: telmex.com, cat: infrastructure, provider: america-movil, cc: MX, imp: 2, tier: 3 }
363 + - { id: etisalat, name: "e& (Etisalat)", host: www.etisalat.ae, cat: infrastructure, provider: etisalat, cc: AE, imp: 2, tier: 3 }
added deploy/.env.example +20 −0
@@ -0,0 +1,20 @@
1 +# InternetPressure.io — production environment for infra/compose.yml (copy to deploy/.env on the server; never commit)
2 +# Generate secrets with: openssl rand -hex 24
3 +
4 +# WireGuard address of this server on the BHS64 hub (edge Caddy :8350 is bound to it only)
5 +WG_IP=10.67.0.61
6 +
7 +# stores
8 +POSTGRES_PASSWORD=change-me
9 +CLICKHOUSE_PASSWORD=change-me
10 +CLICKHOUSE_MEM=12g
11 +
12 +# application
13 +IP_ADMIN_TOKEN=change-me # X-IP-Admin-Token for /api/admin and the /admin UI
14 +IP_SITE_URL=https://www.internetpressure.io
15 +IP_LOG_LEVEL=info
16 +IP_TAG=latest
17 +
18 +# BGP ingestion: empty = every RIS collector (≈10 k prefixes/s). Raw events are sampled 1/25 (IP_BGP_RAW_SAMPLE in settings).
19 +IP_RIS_COLLECTORS=
20 +IP_BGP_STORE_RAW=true
added deploy/bin/deploy.sh +48 −0
@@ -0,0 +1,48 @@
1 +#!/usr/bin/env bash
2 +# Deploy InternetPressure to BHS64b: rsync → build images on the server → compose up → migrate/seed → health.
3 +#
4 +# deploy/bin/deploy.sh full deploy (build + up + migrate + seed + health)
5 +# deploy/bin/deploy.sh up compose up only (no rebuild)
6 +# deploy/bin/deploy.sh build rsync + build images only
7 +# deploy/bin/deploy.sh status docker compose ps + health
8 +# deploy/bin/deploy.sh logs [svc] follow logs
9 +# deploy/bin/deploy.sh route (re)create the public route on the BHS64 gateway
10 +#
11 +# Prerequisites (one-off): deploy/bin/prep-server.sh (Docker, WireGuard wg1 peer to BHS64, ufw), deploy/.env on the server.
12 +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
13 +
14 +cmd="${1:-full}"; shift || true
15 +
16 +health() {
17 + log "health via edge (through WireGuard from the gateway)"
18 + ssh -o BatchMode=yes "$IP_GATEWAY" "curl -s -m 10 -o /dev/null -w 'edge %{http_code} %{time_total}s\n' http://$IP_WG_IP:8350/api/v1/status; \
19 + curl -s -m 10 http://$IP_WG_IP:8350/api/v1/status | head -c 300; echo"
20 + log "public"
21 + curl -s -m 15 -o /dev/null -w "https://$IP_DOMAIN %{http_code} %{time_total}s\n" "https://$IP_DOMAIN/api/v1/status" || warn "public check failed (DNS/route?)"
22 +}
23 +
24 +route() {
25 + log "route https://$IP_DOMAIN → $IP_WG_IP:8350 on $IP_GATEWAY"
26 + ssh -o BatchMode=yes "$IP_GATEWAY" "sudo tunnelctl add $IP_DOMAIN BHS64b:8350 && sudo tunnelctl redirect internetpressure.io $IP_DOMAIN || true; sudo tunnelctl ls | grep -i internetpressure"
27 +}
28 +
29 +case "$cmd" in
30 + full)
31 + sync_repo
32 + compose "build --pull api web"
33 + compose "up -d --remove-orphans"
34 + compose "run --rm migrate"
35 + compose "ps"
36 + sleep 5
37 + health
38 + ;;
39 + build) sync_repo; compose "build --pull api web" ;;
40 + up) sync_repo; compose "up -d --remove-orphans"; compose "ps" ;;
41 + migrate) compose "run --rm migrate" ;;
42 + status) compose "ps"; health ;;
43 + logs) compose "logs -f --tail=200 ${1:-}" ;;
44 + route) route ;;
45 + restart) compose "restart ${1:-}" ;;
46 + *) die "unknown command $cmd" ;;
47 +esac
48 +ok "done"
added deploy/bin/lib.sh +27 −0
@@ -0,0 +1,27 @@
1 +#!/usr/bin/env bash
2 +# Shared helpers for deploy scripts. Server = BHS64b (ubuntu@51.161.112.66), reached through the laptop's SSH alias.
3 +set -euo pipefail
4 +
5 +IP_SERVER="${IP_SERVER:-BHS64b}"
6 +IP_REMOTE_DIR="${IP_REMOTE_DIR:-/opt/internetpressure}"
7 +IP_GATEWAY="${IP_GATEWAY:-BHS64}" # MacLustr Tunnel gateway (Caddy + WireGuard hub)
8 +IP_DOMAIN="${IP_DOMAIN:-www.internetpressure.io}"
9 +IP_WG_IP="${IP_WG_IP:-10.67.0.61}"
10 +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
11 +
12 +log() { printf '\033[1;36m▸\033[0m %s\n' "$*"; }
13 +ok() { printf '\033[1;32m✔\033[0m %s\n' "$*"; }
14 +warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
15 +die() { printf '\033[1;31m✘\033[0m %s\n' "$*" >&2; exit 1; }
16 +
17 +rssh() { ssh -n -o BatchMode=yes -o ConnectTimeout=15 "$IP_SERVER" "$@"; }
18 +
19 +sync_repo() {
20 + log "rsync repo → $IP_SERVER:$IP_REMOTE_DIR"
21 + rssh "sudo mkdir -p $IP_REMOTE_DIR && sudo chown \$(id -un):\$(id -gn) $IP_REMOTE_DIR"
22 + rsync -az --delete \
23 + --exclude-from="$REPO_ROOT/deploy/rsync-exclude.txt" \
24 + "$REPO_ROOT/" "$IP_SERVER:$IP_REMOTE_DIR/"
25 +}
26 +
27 +compose() { rssh "cd $IP_REMOTE_DIR && docker compose -f infra/compose.yml --env-file deploy/.env $*"; }
added deploy/bin/prep-server.sh +47 −0
@@ -0,0 +1,47 @@
1 +#!/usr/bin/env bash
2 +# One-off preparation of BHS64b: Docker (already present), WireGuard wg1 to the BHS64 hub, ufw, directories, .env.
3 +# deploy/bin/prep-server.sh (idempotent)
4 +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
5 +
6 +HUB_PUB="QrpSXW8mIJjGMuB3eiP2LQJGpFgV6lTmBG9v0Cxsiz0=" # BHS64 hub public key (see cluster-skill/maclustr-tunnel/hub-BHS64.pub)
7 +HUB_IP="51.161.112.61"
8 +
9 +log "packages"
10 +rssh "sudo apt-get install -y -qq wireguard-tools traceroute >/dev/null && docker --version && docker compose version"
11 +
12 +log "WireGuard wg1 → BHS64 hub ($IP_WG_IP)"
13 +rssh "sudo test -f /etc/wireguard/wg1.key || (umask 077; wg genkey | sudo tee /etc/wireguard/wg1.key >/dev/null)"
14 +PUB=$(rssh "sudo cat /etc/wireguard/wg1.key | wg pubkey")
15 +rssh "sudo tee /etc/wireguard/wg1.conf >/dev/null <<EOF
16 +[Interface]
17 +Address = $IP_WG_IP/24
18 +PrivateKey = \$(sudo cat /etc/wireguard/wg1.key)
19 +[Peer]
20 +# BHS64 hub (MacLustr Tunnel public gateway)
21 +PublicKey = $HUB_PUB
22 +AllowedIPs = 10.67.0.1/32
23 +Endpoint = $HUB_IP:51820
24 +PersistentKeepalive = 25
25 +EOF
26 +sudo chmod 600 /etc/wireguard/wg1.conf && sudo systemctl enable --now wg-quick@wg1 >/dev/null 2>&1; sudo systemctl restart wg-quick@wg1; sudo wg show wg1 | head -3"
27 +
28 +log "register peer BHS64b on the gateway"
29 +ssh -o BatchMode=yes "$IP_GATEWAY" "grep -q '^BHS64b' /etc/maclustr-tunnel/ipmap || echo 'BHS64b $IP_WG_IP' | sudo tee -a /etc/maclustr-tunnel/ipmap >/dev/null; sudo tunnelctl peer add BHS64b $PUB; sudo tunnelctl peer ls | grep BHS64b"
30 +sleep 3
31 +ssh -o BatchMode=yes "$IP_GATEWAY" "ping -c 2 -W 2 $IP_WG_IP | tail -1"
32 +
33 +log "firewall (ufw): keep 22 + wireguard; nothing else public (edge is bound to the wg address)"
34 +rssh "sudo ufw allow 51820/udp >/dev/null; sudo ufw status | head -5"
35 +
36 +log "directories + env"
37 +rssh "sudo mkdir -p $IP_REMOTE_DIR && sudo chown \$(id -un):\$(id -gn) $IP_REMOTE_DIR"
38 +if ! rssh "test -f $IP_REMOTE_DIR/deploy/.env"; then
39 + warn "no deploy/.env on the server yet — generating one from deploy/.env.example with random secrets"
40 + PG=$(openssl rand -hex 24); CH=$(openssl rand -hex 24); AT=$(openssl rand -hex 24)
41 + sed -e "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PG/" -e "s/^CLICKHOUSE_PASSWORD=.*/CLICKHOUSE_PASSWORD=$CH/" \
42 + -e "s/^IP_ADMIN_TOKEN=.*/IP_ADMIN_TOKEN=$AT/" -e "s/^WG_IP=.*/WG_IP=$IP_WG_IP/" "$REPO_ROOT/deploy/.env.example" > /tmp/ip-env
43 + rssh "mkdir -p $IP_REMOTE_DIR/deploy" && scp -q /tmp/ip-env "$IP_SERVER:$IP_REMOTE_DIR/deploy/.env" && rm /tmp/ip-env
44 + mkdir -p "$HOME/.internetpressure" && rssh "cat $IP_REMOTE_DIR/deploy/.env" > "$HOME/.internetpressure/env.bhs64b" && chmod 600 "$HOME/.internetpressure/env.bhs64b"
45 + ok "secrets saved locally in ~/.internetpressure/env.bhs64b"
46 +fi
47 +ok "server prepared"
added deploy/bin/probes.sh +73 −0
@@ -0,0 +1,73 @@
1 +#!/usr/bin/env bash
2 +# Build the Go probe agent and (re)install it on every node listed in data/seed/probes.yaml.
3 +#
4 +# deploy/bin/probes.sh build cross-compile dist/ip-probe-{darwin-arm64,linux-amd64}
5 +# deploy/bin/probes.sh keys print probe ids + keys from the production API (BHS64b)
6 +# deploy/bin/probes.sh install [probe_id…] push binary + installer to the node(s) and (re)start the service
7 +# deploy/bin/probes.sh status /healthz of every probe
8 +#
9 +# Sudo passwords for the rented Macs live OUTSIDE the repo in ~/.internetpressure/sudo.txt ("<node> <password>" per
10 +# line; nodes with passwordless sudo are simply absent). Keys are read from the production Postgres through
11 +# `docker compose run --rm cli probe-key <id>` on BHS64b and never written to disk here.
12 +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
13 +
14 +AGENT="$REPO_ROOT/services/probe-agent"
15 +SUDO_FILE="$HOME/.internetpressure/sudo.txt"
16 +INGEST_URL="${IP_INGEST_URL:-https://$IP_DOMAIN/ingest/v1}"
17 +
18 +probes_nodes() { # "probe_id node" pairs from the seed
19 + python3 - "$REPO_ROOT/data/seed/probes.yaml" <<'EOF'
20 +import sys, yaml
21 +for p in yaml.safe_load(open(sys.argv[1]))["probes"]:
22 + print(p["probe_id"], p["node"])
23 +EOF
24 +}
25 +
26 +probe_key() { compose "run --rm --no-deps -T cli probe-key $1" 2>/dev/null | tail -1 | tr -d '\r'; }
27 +
28 +sudo_pw() { [[ -f "$SUDO_FILE" ]] && awk -v n="$1" '$1==n {print $2}' "$SUDO_FILE" || true; }
29 +
30 +install_one() {
31 + local pid="$1" node="$2" key="$3"
32 + local os arch bin
33 + os=$(ssh -n -o BatchMode=yes -o ConnectTimeout=20 "$node" 'uname -s | tr A-Z a-z') || { warn "$node unreachable"; return 1; }
34 + arch=$(ssh -n -o BatchMode=yes "$node" 'uname -m')
35 + case "$arch" in arm64|aarch64) arch=arm64;; x86_64|amd64) arch=amd64;; esac
36 + bin="$AGENT/dist/ip-probe-$os-$arch"
37 + [[ -x "$bin" ]] || die "missing $bin (run: probes.sh build)"
38 + log "$pid → $node ($os/$arch)"
39 + ssh -n -o BatchMode=yes "$node" 'mkdir -p ~/ip-probe/dist ~/ip-probe/deploy/launchd ~/ip-probe/deploy/systemd'
40 + scp -q "$bin" "$node:~/ip-probe/dist/"
41 + scp -q "$AGENT/deploy/install.sh" "$node:~/ip-probe/deploy/install.sh"
42 + scp -q "$AGENT/deploy/launchd/io.internetpressure.probe.plist" "$node:~/ip-probe/deploy/launchd/"
43 + scp -q "$AGENT/deploy/systemd/ip-probe.service" "$node:~/ip-probe/deploy/systemd/"
44 + local pw; pw=$(sudo_pw "$node")
45 + if [[ -n "$pw" ]]; then
46 + ssh -n -o BatchMode=yes "$node" "chmod +x ~/ip-probe/deploy/install.sh; printf '%s\n' '$pw' | sudo -S -p '' bash ~/ip-probe/deploy/install.sh '$pid' '$key' '$INGEST_URL' 2>&1 | tail -6"
47 + else
48 + ssh -n -o BatchMode=yes "$node" "chmod +x ~/ip-probe/deploy/install.sh; sudo -n bash ~/ip-probe/deploy/install.sh '$pid' '$key' '$INGEST_URL' 2>&1 | tail -6"
49 + fi
50 +}
51 +
52 +cmd="${1:-status}"; shift || true
53 +case "$cmd" in
54 + build) (cd "$AGENT" && make build) ;;
55 + keys) while read -r pid node; do printf '%-12s %-8s %s\n' "$pid" "$node" "$(probe_key "$pid")"; done < <(probes_nodes) ;;
56 + install)
57 + want=("$@")
58 + while read -r pid node; do
59 + if [[ ${#want[@]} -gt 0 ]] && [[ ! " ${want[*]} " =~ " $pid " ]]; then continue; fi
60 + key=$(probe_key "$pid")
61 + [[ "$key" =~ ^[0-9a-f]{64}$ ]] || { warn "no key for $pid (seed the registry first)"; continue; }
62 + install_one "$pid" "$node" "$key" || true
63 + done < <(probes_nodes)
64 + ;;
65 + status)
66 + while read -r pid node; do
67 + printf '%-12s %-8s ' "$pid" "$node"
68 + ssh -n -o BatchMode=yes -o ConnectTimeout=15 "$node" 'curl -s -m 3 http://127.0.0.1:9381/healthz' 2>/dev/null | head -c 200 || printf 'unreachable'
69 + echo
70 + done < <(probes_nodes)
71 + ;;
72 + *) die "unknown command $cmd" ;;
73 +esac
added deploy/rsync-exclude.txt +20 −0
@@ -0,0 +1,20 @@
1 +.git/
2 +.venv/
3 +__pycache__/
4 +.pytest_cache/
5 +.ruff_cache/
6 +*.egg-info/
7 +node_modules/
8 +apps/web/.next/
9 +apps/web/qa/screens/
10 +apps/web/mock/
11 +services/probe-agent/dist/
12 +data/asn/*.gz
13 +tmp/
14 +logs/
15 +.DS_Store
16 +.claude/
17 +.env
18 +.env.*
19 +deploy/.env
20 +deploy/probe-keys/
added docs/API.md +267 −0
@@ -0,0 +1,267 @@
1 +# InternetPressure public API (v1) — contract
2 +
3 +Served by `apps/api` (FastAPI) on `:8352`; the Next app (`apps/web`, `:8351`) reaches it through the edge on the same
4 +origin (`/api/*` → api, `/ingest/*` → api, everything else → web). Browser code therefore always calls relative URLs
5 +(`/api/v1/...`). All timestamps are UTC ISO-8601 with `Z`. All numbers are plain JSON numbers (never strings).
6 +Every response carries `Cache-Control: no-store` unless stated. Unknown scope → `404 {"error":"not_found"}`.
7 +
8 +Rate limit (public tier): 120 req/min per IP; SSE connections: 4 per IP. `429` with `Retry-After`.
9 +
10 +Pressure levels (from `packages/config/pressure.yaml`): `calm ≤10`, `normal ≤25`, `elevated ≤40`, `stressed ≤55`,
11 +`high ≤70`, `severe ≤85`, `extreme ≤100`. Every `level` field is one of these ids; `level_label` is its label.
12 +
13 +Internal status (self-exclusion, §57 of the spec): `internal_status` is `"ok"`, `"degraded"` (too few fresh probes /
14 +BGP stale / stores unhealthy — score is frozen at its last value and `stale: true`) or `"stale"` (engine hasn't run).
15 +The UI must show a visible "instrument degraded" state instead of interpreting a frozen number.
16 +
17 +---
18 +
19 +## GET /api/v1/status
20 +```json
21 +{ "ok": true, "ts": "…", "internal_status": "ok", "engine": { "last_run": "…", "cycle_ms": 412, "cycle_seconds": 10 },
22 + "ingest": { "last_batch": "…", "batches_5m": 312, "measurements_5m": 41200 },
23 + "probes": { "fresh": 8, "total": 8, "excluded": [] },
24 + "bgp": { "fresh": true, "last_message": "…", "collectors": 12 },
25 + "stores": { "clickhouse": true, "postgres": true, "redis": true }, "version": "0.1.0" }
26 +```
27 +
28 +## GET /api/v1/pressure/global
29 +```json
30 +{
31 + "ts": "…", "pressure": 42.7, "level": "stressed", "level_label": "Stressed",
32 + "delta_1h": 6.3, "delta_24h": -2.1, "velocity_per_h": 7.2, "acceleration_per_h2": 3.1, "volatility_1h": 2.4,
33 + "trend": "rising", // rising | falling | stable
34 + "confidence": 0.86, "stale": false, "internal_status": "ok",
35 + "coverage": { "probes_active": 8, "probes_total": 8, "probe_regions": 5, "targets": 212,
36 + "measurements_5m": 41200, "bgp_collectors": 12, "baseline_days": 6.2 },
37 + "components": [
38 + { "id": "routing", "label": "Routing", "score": 57.1, "weight": 0.25, "contribution": 14.3,
39 + "trend": "rising", "delta_1h": 11.0, "confidence": 0.9,
40 + "drivers": [ { "label": "BGP withdrawals/s 4.8× baseline", "points": 9.1, "scope_type": "bgp", "scope_id": "withdrawals" } ] },
41 + { "id": "latency", … }, { "id": "dns", … }, { "id": "availability", … }, { "id": "http_tls", … },
42 + { "id": "path", … }, { "id": "corroboration", … }
43 + ],
44 + "explain": [
45 + { "text": "+14.3 points from elevated BGP route churn", "points": 14.3, "component": "routing", "scope_type": "global", "scope_id": null },
46 + { "text": "+11.0 from North America East packet loss", "points": 11.0, "component": "latency", "scope_type": "region", "scope_id": "na-east" },
47 + { "text": "−3.1 because Western Europe remains stable", "points": -3.1, "component": "latency", "scope_type": "region", "scope_id": "eu-west" }
48 + ],
49 + "sparkline_1h": [ 36.1, 36.4, … ] // 60 points, 1 per minute, oldest first (null where missing)
50 +}
51 +```
52 +
53 +## GET /api/v1/pressure/history?scope_type=global|region|country|asn|service|component&scope_id=…&range=1h|6h|24h|7d|30d|1y
54 +Server-side aggregation; step is chosen by the server (`1h→10s`, `6h→1m`, `24h→1m`, `7d→5m`, `30d→1h`, `1y→1d`).
55 +```json
56 +{ "scope_type": "global", "scope_id": null, "range": "24h", "step_seconds": 60,
57 + "points": [ { "ts": "…", "pressure": 41.2, "components": { "routing": 55.0, "latency": 40.1, "dns": 12.0,
58 + "availability": 30.3, "http_tls": 22.0, "path": 47.9, "corroboration": 0.0 }, "confidence": 0.85 } ],
59 + "summary": { "min": 21.0, "max": 61.4, "avg": 33.7, "max_ts": "…" } }
60 +```
61 +`Cache-Control: public, max-age=30` for ranges ≥ 24h.
62 +
63 +## GET /api/v1/pressure/regions
64 +```json
65 +{ "ts": "…", "regions": [
66 + { "id": "na-east", "name": "North America East", "continent": "North America", "lat": 43, "lon": -76,
67 + "pressure": 51.3, "level": "stressed", "level_label": "Stressed", "delta_1h": 12.1, "trend": "rising", "confidence": 0.8,
68 + "components": { "routing": null, "latency": 63.2, "dns": 10.1, "availability": 35.0, "http_tls": 20.0, "path": 58.0 },
69 + "probes": 3, "targets": 41, "incidents": 1, "coverage_ok": true, "role": "both" } ] } // role: probe | target | both
70 +```
71 +Regional pressure = stress observed **from** probes in the region (source view) blended with stress observed **toward**
72 +targets anchored in the region (destination view); `routing` is null for regions without ASN attribution.
73 +
74 +## GET /api/v1/pressure/region/{id}
75 +Region object above plus: `history_24h` (`{step_seconds, points:[{ts,pressure}]}`), `baseline_7d` (`{median, p90}`),
76 +`incidents` (list, see incidents), `top_asns` (`[{asn, name, pressure}]`), `top_services` (`[{slug, name, pressure, observed_availability_24h}]`),
77 +`probes` (probe list objects), `matrix` (latency matrix rows for this region, see /latency).
78 +
79 +## GET /api/v1/pressure/countries
80 +```json
81 +{ "ts": "…", "countries": [ { "cc": "CA", "name": "Canada", "region": "na-east", "lat": 56.1, "lon": -106.3,
82 + "pressure": 34.0, "level": "elevated", "level_label": "Elevated", "delta_1h": 2.0, "trend": "stable",
83 + "components": { … }, "probes": 2, "targets": 18, "role": "both", "coverage_ok": true } ] }
84 +```
85 +Only countries where we have at least one probe or one anchored target appear. The map colours these; every other
86 +country is drawn neutral (we do not pretend to observe it).
87 +
88 +## GET /api/v1/pressure/country/{cc}
89 +Country object plus `history_24h`, `baseline_7d`, `incidents`, `asns`, `services`, `probes`, `targets`
90 +(`[{target_id, name, category, pressure, ok_ratio_1h, ttfb_ms_median}]`).
91 +
92 +## GET /api/v1/asns
93 +`{ "ts": …, "asns": [ { "asn": 13335, "name": "Cloudflare", "country": "US", "pressure": 18.2, "level": "normal",
94 +"routing": 12.0, "latency": 22.1, "availability": 9.0, "targets": 6, "prefixes_observed": 1200, "importance": 5 } ] }`
95 +
96 +## GET /api/v1/pressure/asn/{asn}
97 +```json
98 +{ "asn": 13335, "name": "Cloudflare, Inc.", "country": "US", "importance": 5, "ts": "…",
99 + "pressure": 18.2, "level": "normal", "level_label": "Normal", "delta_1h": -1.0, "trend": "falling", "confidence": 0.7,
100 + "components": { "routing": 12.0, "latency": 22.1, "availability": 9.0, "dns": 4.0, "http_tls": 6.0, "path": 15.0 },
101 + "bgp": { "prefixes_observed_24h": 1200, "announcements_1h": 340, "withdrawals_1h": 12, "churn_ratio": 1.1,
102 + "origin_changes_1h": 0, "path_stability": 0.97, "series_24h": [ { "ts": "…", "announcements": 5, "withdrawals": 0 } ] },
103 + "regions_observed": [ "na-east", "eu-west", "eu-east-med" ],
104 + "targets": [ { "target_id": "cloudflare-www", "name": "Cloudflare", "pressure": 12.0, "ok_ratio_1h": 1.0, "ttfb_ms_median": 71.0 } ],
105 + "history_24h": { "step_seconds": 60, "points": [ … ] }, "incidents": [ … ] }
106 +```
107 +
108 +## GET /api/v1/services
109 +`{ "services": [ { "slug": "cloudflare", "name": "Cloudflare", "category": "cdn", "pressure": 12.0, "level": "normal",
110 +"observed_availability_24h": 0.9994, "targets": 6, "affected_regions": [], "vendor_status": { "indicator": "none", "incidents": 0, "source": "status.cloudflare.com", "checked_at": "…" } } ] }`
111 +`vendor_status` is null when we have no connector for that provider.
112 +
113 +## GET /api/v1/service/{slug}
114 +Service object plus:
115 +```json
116 +{ "observed": { "availability_24h": 0.9994, "availability_1h": 1.0, "ttfb_ms_median_1h": 71.0, "ttfb_ms_baseline": 68.0,
117 + "tls_ms_median_1h": 30.1, "failures_1h": 2 },
118 + "affected_regions": [ { "id": "na-east", "name": "…", "observation": "Elevated TLS latency from 2 probes" } ],
119 + "vendor_status": { "indicator": "minor", "incidents": 1, "titles": ["…"], "source": "…", "url": "…", "checked_at": "…" },
120 + "discrepancy": "Vendor reports no incident; we observe elevated TLS latency from 4 probe regions.", // or null
121 + "matrix": [ { "probe_id": "ca-qc-01", "probe_region": "na-east", "targets": [ { "target_id": "…", "ok": true, "ttfb_ms": 70.0, "z": 0.4, "ts": "…" } ] } ],
122 + "targets": [ … ], "history_24h": { … }, "incidents": [ … ] }
123 +```
124 +
125 +## GET /api/v1/targets · GET /api/v1/target/{id}
126 +List: `{ "targets": [ { "target_id", "name", "hostname", "category", "provider", "service_id", "country", "region", "importance", "tier", "pressure", "ok_ratio_1h", "ttfb_ms_median_1h" } ] }`.
127 +Detail adds `latest_by_probe` (`[{probe_id, kind, ts, ok, error, dns_ms, tcp_ms, tls_ms, ttfb_ms, http_status, resolved_ip, packet_loss, rtt_avg_ms, z}]`),
128 +`series_24h` (`{step_seconds, points:[{ts, ttfb_ms_p50, ok_ratio}]}`) and `dns` (`{ resolvers: [{resolver, rcode, answers, ms}], disagreement: false }`).
129 +
130 +## GET /api/v1/probes
131 +```json
132 +{ "probes": [ { "probe_id": "ca-qc-01", "name": "Québec City (Bell)", "region": "na-east", "country": "CA", "city": "Québec",
133 + "provider": "Bell Canada", "asn": 577, "lat": 46.8, "lon": -71.2, "status": "online", // online | stale | offline | excluded
134 + "last_seen": "…", "version": "0.1.0", "measurements_1h": 5120, "uptime_24h": 0.998, "clock_offset_ms": -14,
135 + "capabilities": ["http","dns","ping","traceroute"] } ] }
136 +```
137 +
138 +## GET /api/v1/incidents?status=active|resolved|all&limit=50&offset=0
139 +```json
140 +{ "total": 3, "incidents": [ {
141 + "event_id": "evt_01J…", "slug": "2026-09-12-north-america-east-latency-anomaly",
142 + "type": "regional_latency", // regional_latency | dns_disruption | routing_instability | service_degradation | availability_loss | path_instability | global_pressure
143 + "title": "North America East latency anomaly", "summary": "Elevated latency and packet loss observed from 3 probes toward 41 targets.",
144 + "status": "active", // detected | developing | active | recovering | resolved
145 + "scope_type": "region", "scope_id": "na-east", "scope_label": "North America East",
146 + "started_at": "…", "updated_at": "…", "ended_at": null, "duration_s": 1260,
147 + "peak_pressure": 76.0, "current_pressure": 71.2, "confidence": 0.93,
148 + "affected_probes": 3, "affected_targets": 41, "affected_asns": [577, 16276], "affected_services": ["aws", "github"],
149 + "hypotheses": [ { "text": "Possible upstream transit issue", "confidence": 0.6,
150 + "evidence": ["Route fingerprints changed on 62 % of paths", "Latency rose on paths crossing AS6453"] } ]
151 +} ] }
152 +```
153 +
154 +## GET /api/v1/incident/{slug}
155 +Incident object plus `timeline` (`[{ts, status, pressure, note}]`), `evidence` (`[{signal_id, label, scope_type, scope_id,
156 +current, baseline, robust_z, samples, ts}]`), `series` (`{step_seconds, points:[{ts, pressure, global_pressure}]}` from 30 min
157 +before start to now/end), `probes` (`[{probe_id, region, observation}]`), `targets` (`[{target_id, name, service_id, observation}]`),
158 +`bgp` (`{withdrawals_ratio, announcements_ratio, origin_changes}` or null), `annotations` (`[{ts, author, text}]`).
159 +
160 +## GET /api/v1/fronts
161 +```json
162 +{ "ts": "…", "fronts": [ { "id": "front_na-east_eu-west", "name": "North Atlantic Pressure Front", "status": "developing",
163 + "intensity": 74.0, "confidence": 0.89, "direction": "east", "since": "…",
164 + "from": { "region": "na-east", "name": "North America East", "lat": 43, "lon": -76 },
165 + "to": { "region": "eu-west", "name": "Western Europe", "lat": 49, "lon": 3 },
166 + "observed": { "latency_pct": 43.0, "churn_x": 4.8, "loss_pct": 3.1, "pairs": 17, "targets": 17, "route_changes": 9 } } ] }
167 +```
168 +
169 +## GET /api/v1/bgp/stats
170 +```json
171 +{ "ts": "…", "fresh": true, "updates_per_s": 812.4, "announcements_per_s": 760.0, "withdrawals_per_s": 52.4,
172 + "baseline": { "announcements_per_s": 640.0, "withdrawals_per_s": 11.0 }, "ratio": { "announcements": 1.19, "withdrawals": 4.76 },
173 + "unique_prefixes_1m": 14211, "unique_origins_1m": 2210, "origin_changes_1m": 3, "peers": 1450,
174 + "collectors": [ { "id": "rrc00", "location": "Amsterdam", "announcements_per_s": 120.1, "withdrawals_per_s": 8.0, "peers": 210, "last_message": "…", "fresh": true } ],
175 + "series_1h": [ { "ts": "…", "announcements": 7600, "withdrawals": 520 } ], // per minute
176 + "top_origins_1h": [ { "asn": 13335, "name": "Cloudflare", "announcements": 340, "withdrawals": 12 } ] }
177 +```
178 +
179 +## GET /api/v1/latency
180 +```json
181 +{ "ts": "…", "global": { "rtt_ms_median": 41.2, "rtt_ms_baseline": 39.0, "ttfb_ms_median": 118.0, "ttfb_ms_baseline": 110.0, "packet_loss_pct": 0.3 },
182 + "matrix": [ { "from": "na-east", "to": "eu-west", "rtt_ms": 92.1, "rtt_ms_baseline": 88.0, "ttfb_ms": 160.0, "loss_pct": 0.0, "z": 0.6, "pairs": 37 } ],
183 + "by_probe": [ { "probe_id": "ca-qc-01", "rtt_ms_median": 30.1, "ttfb_ms_median": 90.0, "loss_pct": 0.0, "z": 0.2 } ] }
184 +```
185 +
186 +## GET /api/v1/ticker
187 +```json
188 +{ "ts": "…", "bgp_updates_per_s": 812.4, "bgp_withdrawals_per_s": 52.4, "bgp_updates_per_min": 48744,
189 + "probes_active": 8, "probes_total": 8, "measurements_per_s": 12.3, "measurements_per_min": 738,
190 + "targets_degraded": 4, "targets_total": 212, "regions_elevated": 3, "regions_normal": 9, "regions_severe": 0,
191 + "dns_failures_per_min": 2, "median_global_rtt_ms": 41.2, "route_changes_per_min": 1.2, "active_incidents": 1,
192 + "internal_status": "ok" }
193 +```
194 +
195 +## GET /api/v1/routes?probe={probe_id}&target={target_id}
196 +```json
197 +{ "probe": { "probe_id": "…", "name": "…", "asn": 577 }, "target": { "target_id": "…", "name": "…", "hostname": "…", "asn": 13335 },
198 + "current": { "ts": "…", "route_hash": "…", "reached": true, "total_ms": 30.2,
199 + "hops": [ { "n": 1, "ip": "192.168.2.1", "asn": null, "asn_name": null, "rtt_ms": 1.2, "private": true } ] },
200 + "baseline": { "route_hash": "…", "share_7d": 0.82, "first_seen": "…", "last_seen": "…", "hops": [ … ] },
201 + "diff": { "changed": true, "added": [ { "n": 6, "ip": "…", "asn": 6453 } ], "removed": [ … ],
202 + "asn_path_current": [577, 6453, 13335], "asn_path_baseline": [577, 577, 13335], "latency_shift_ms": 12.4, "hop_delta": 1 },
203 + "history_24h": [ { "ts": "…", "route_hash": "…", "hop_count": 11, "total_ms": 30.2 } ],
204 + "route_share_7d": [ { "route_hash": "…", "share": 0.82, "asn_path": [ … ] } ] }
205 +```
206 +`GET /api/v1/routes/pairs` → `{ "pairs": [ { "probe_id", "target_id", "changed_24h": 3, "current_route_hash", "stable": false } ] }`.
207 +
208 +## GET /api/v1/history/summary?year=2026&month=9
209 +```json
210 +{ "year": 2026, "month": 9, "days": [ { "date": "2026-09-12", "min": 18.0, "max": 61.4, "avg": 30.2, "events": 2 } ],
211 + "top_events": [ incident objects, by peak_pressure ], "top_asns": [ { "asn", "name", "events", "max_pressure" } ],
212 + "top_regions": [ { "id", "name", "events", "max_pressure", "hours_elevated" } ],
213 + "largest": { "pressure": {…incident}, "routing": {…}, "dns": {…}, "latency": {…} }, "available_months": ["2026-09"] }
214 +```
215 +Without `month` → per-month rows in `months` instead of `days`. Without `year` → all years.
216 +
217 +## GET /api/v1/explain
218 +Deep explainability: `{ "ts", "pressure", "components": [ { "id", "score", "weight", "contribution", "signals": [
219 +{ "signal_id": "ttfb_z", "label": "…", "scope_type": "region", "scope_id": "na-east", "current": 161.0, "baseline_median": 110.0,
220 + "mad": 9.0, "robust_z": 5.6, "samples": 412, "stress": 0.71, "contribution": 6.2 } ] } ], "excluded_probes": [], "notes": [ "…" ] }`
221 +
222 +## GET /api/v1/methodology
223 +Public copy of the scoring config: `{ "weights": {…}, "levels": [ … ], "engine": { "cycle_seconds", "baseline_days", "z_anomaly", … }, "version": 1, "updated_at": "…" }`.
224 +
225 +## GET /api/v1/search?q=
226 +`{ "results": [ { "type": "country|asn|service|region|target|incident", "id": "…", "label": "…", "href": "/asn/13335", "pressure": 12.0 } ] }`
227 +
228 +## GET /api/v1/live — Server-Sent Events
229 +Headers `Content-Type: text/event-stream`, `Cache-Control: no-store`, `X-Accel-Buffering: no`. Sends `retry: 5000`, an `id:` per
230 +message, a `: ping` comment every 15 s, and on connect an immediate `snapshot` event.
231 +
232 +| event | data |
233 +|---|---|
234 +| `snapshot` | `{ "global": <GET /pressure/global>, "ticker": <GET /ticker>, "regions": <regions[]>, "fronts": <fronts[]>, "incidents": <active incidents[]> }` |
235 +| `global_pressure_update` | same shape as `GET /pressure/global` (sent every engine cycle, i.e. only when the engine actually ran) |
236 +| `regional_pressure_update` | `{ "ts", "regions": [ … ], "countries": [ { "cc", "pressure", "level", "delta_1h" } ] }` |
237 +| `ticker` | `<GET /ticker>` (every 5 s, from real counters) |
238 +| `bgp_stats` | `{ "ts", "updates_per_s", "announcements_per_s", "withdrawals_per_s", "ratio": {…}, "fresh" }` (every 5 s) |
239 +| `probe_stats` | `{ "ts", "probes_active", "probes_total", "measurements_per_s", "excluded": [] }` |
240 +| `incident_created` / `incident_updated` | incident object |
241 +| `service_degradation` | `{ "ts", "slug", "name", "pressure", "regions": [ … ], "observation": "…" }` |
242 +| `front_update` | `<GET /fronts>` |
243 +| `internal_status` | `{ "ts", "internal_status", "reason" }` |
244 +
245 +Nothing is ever emitted without a corresponding backend computation — if the engine pauses, the stream only pings.
246 +
247 +---
248 +
249 +## Admin API (`/api/admin/*`, header `X-IP-Admin-Token: <token>`)
250 +
251 +- `GET /api/admin/overview` → `{ "probes": [ { …probe, "health": { "uptime_24h", "clock_offset_ms", "missing_ratio_1h", "error_rate_1h",
252 + "buffered", "spool_bytes", "version", "last_health" } } ], "ingest": { "batches_per_min", "measurements_per_min", "rejected_per_min", "last_batch" },
253 + "stores": { "clickhouse": { "ok", "inserts_per_s", "tables": [ { "name", "rows", "bytes", "oldest", "newest" } ] }, "postgres": { "ok", "size_bytes" }, "redis": { "ok", "used_memory_bytes", "keys" } },
254 + "bgp": { "collectors": [ … ], "messages_per_s", "fresh", "reconnects_24h" }, "engine": { "last_run", "cycle_ms_p50", "cycle_ms_max", "runs_1h", "errors_1h", "internal_status", "excluded_probes" },
255 + "corroboration": [ { "id", "name", "ok", "last_fetch", "incidents" } ] }`
256 +- `GET /api/admin/targets` · `POST /api/admin/targets` (target object) · `PATCH /api/admin/targets/{id}` · `DELETE /api/admin/targets/{id}`
257 +- `GET /api/admin/probes` · `POST /api/admin/probes` `{probe_id, name, region, country, city, provider, asn, lat, lon}` → `{ …probe, "key": "<hex, shown once>" }`
258 + · `PATCH /api/admin/probes/{id}` (`enabled`, metadata) · `POST /api/admin/probes/{id}/rotate-key` → new key
259 +- `GET /api/admin/config` → full `pressure.yaml` as JSON · `PUT /api/admin/config` (validates: weights sum to 1 ± 0.001) → stored in Postgres `config` and applied next cycle
260 +- `GET /api/admin/baselines?signal_id=&scope_type=&scope_id=` → `{ "signal_id", "points": [ { "ts", "value", "median", "mad", "z" } ], "samples", "baseline_days" }`
261 +- `GET /api/admin/raw?table=measurements|traceroutes|bgp_events|bgp_stats|pressure_history|signal_features|probe_health&probe_id=&target_id=&limit=200` → `{ "columns": [ … ], "rows": [ [ … ] ] }`
262 +- `GET /api/admin/incidents?status=` · `PATCH /api/admin/incidents/{id}` `{ "review": "confirmed|dismissed|unreviewed", "note": "…" }`
263 +- `POST /api/admin/annotations` `{ "ts", "scope_type", "scope_id", "text" }` · `GET /api/admin/annotations`
264 +- `POST /api/admin/replay` `{ "from": "…", "to": "…", "weights": {…} }` → `{ "step_seconds", "points": [ { "ts", "pressure_original", "pressure_replayed" } ] }`
265 +- `POST /api/admin/boost` `{ "targets": [ … ], "factor": 0.5, "seconds": 900 }` — manual sampling boost pushed to probes.
266 +
267 +Errors: `401 {"error":"unauthorized"}`, `422 {"error":"validation", "detail": …}`.
added docs/ARCHITECTURE.md +82 −0
@@ -0,0 +1,82 @@
1 +# Architecture
2 +
3 +```
4 + 8 probes (Go, launchd/systemd) RIPE RIS Live (wss) public status pages (optional)
5 + Québec · Montréal · Atlanta · Gravelines 23 collectors, ~10k prefixes/s Statuspage / GCP / AWS RSS / status.io
6 + Dublin · Istanbul · Uşak · Ayia Napa │ │
7 + │ signed gzip batches (HTTPS) │ │
8 + ▼ ▼ ▼
9 + ┌──────────────── BHS64b (OVH Beauharnois, Docker Compose) ──────────────────────────────────────┐
10 + │ edge Caddy :8350 (bound to wg1 10.67.0.61) ── /ingest,/api → api :8352 ── / → web :8351 │
11 + │ api (FastAPI) ── ingest → ClickHouse writer + Redis live counters │
12 + │ bgp (RIS Live) ── 10 s per-collector aggregates, per-origin minutes, sampled raw │
13 + │ corroboration ── vendor_status (Postgres) │
14 + │ engine (10 s) ── baselines (5 min) → robust z → components → scopes → global → events → fronts │
15 + │ → Redis live state + pub/sub → SSE ; ClickHouse pressure_history/provenance │
16 + │ Postgres 17 (registry, config, events) ClickHouse 25.8 (telemetry, history) Redis 7 (live) │
17 + └──────────────────────────────────────────────────────────────────────────────────────────────────┘
18 + ▲ WireGuard 10.67.0.0/24
19 + BHS64 gateway: Caddy TLS https://www.internetpressure.io → 10.67.0.61:8350 (DNS A → 51.161.112.61)
20 +```
21 +
22 +## Decisions
23 +
24 +- **Python (FastAPI) backend, Go probe.** The spec mandates Go for the agent; the backend language is free. Python matches the
25 + other MacLustr platforms (AI Atlas, SatelliteIndex, CountryAtlas) and keeps the scoring code readable/testable. All stores are
26 + spoken to over their wire protocols (asyncpg, ClickHouse HTTP, redis) — no ORM.
27 +- **One backend image, four processes** (`ip api`, `ip engine`, `ip bgp`, `ip corroboration`). They share nothing but the stores.
28 +- **ClickHouse HTTP interface** via httpx (JSONEachRow) instead of a driver: async, dependency-free, trivially inspectable.
29 +- **Baselines from raw measurements** (180 d TTL) recomputed every 5 minutes; MAD estimated from the IQR (0.7413 × IQR) in a
30 + single pass; hour-of-day seasonality (±1 h) once ≥ 3 days of history exist. `measurements_1m` (AggregatingMergeTree MV, 1 y)
31 + serves long-range charts.
32 +- **Self-exclusion** (`engine/health.py`): < 2 fresh usable probes, every probe failing at once, or a store down ⇒
33 + `internal_status = degraded`, score frozen (`stale: true`), no history rows, no incidents. A probe failing ≥ 80 % of its HTTP
34 + targets while the median probe is fine is excluded (its uplink, not the Internet).
35 +- **Calibration**: until at least one signal has ≥ 20 % of `baseline_min_samples` (12 five-minute buckets), the instrument
36 + publishes `pressure: null`, `level: calibrating`. Nothing is invented.
37 +- **Config-driven scoring**: `packages/config/pressure.yaml`, optionally overridden by the Postgres `config` row (admin UI); the
38 + engine re-reads it every cycle. Weights must sum to 1 (validated).
39 +- **Explainability**: every cycle stores `signal_features` (component, signal, scope, current, baseline, MAD, z, samples, stress,
40 + contribution) and the live payload carries `explain[]` (attribution = component contribution × share of stress mass by region /
41 + signal / service) and `components[].drivers[]`.
42 +
43 +## Scoring pipeline (engine/compute.py, engine/scoring.py)
44 +
45 +1. **Pair signals**: per (probe, target, kind, resolver) in the last 120 s: latency z (ttfb, tcp, rtt), loss excess, failure-rate
46 + excess, 5xx/TLS/reset rates, DNS failure & latency; per target: corroborated unavailability (≥ 2 probes in ≥ 2 regions) and
47 + resolver disagreement (one resolver answers, another SERVFAILs); per (probe, target) traceroute: route fingerprint changed vs
48 + the 7-day dominant route, hop-count z, latency shift on changed paths.
49 +2. **Pair stress** 0..1: `z_to_stress` ramps from z = 1 to z = 6; rate signals use `rate_stress` (excess / max(scale, 3 × baseline));
50 + route churn uses `ratio_stress`.
51 +3. **Signal stress** = importance-weighted mean of pair stresses; the weight is damped by baseline coverage
52 + (`coverage_factor`), so thin baselines cannot move the index.
53 +4. **Component score** = `saturate(Σ signal_weight × signal_stress)`, `saturate(x) = 100·(1−e^{−kx})/(1−e^{−k})`, k = 1.2.
54 +5. **Routing** (global only, ASN level via `bgp_origin_1m`): weighted-median robust z across collectors of per-minute
55 + withdrawals / announcements / origin changes vs their 7-day (seasonal) baseline; collector disagreement when a minority of
56 + collectors spike.
57 +6. **Corroboration**: Σ impact(indicator) × service importance / 4, capped at 1 — weight 0.05, optional by design.
58 +7. **Global** = Σ weight × component over available components (weights renormalised). Regions/countries/services/ASNs reuse
59 + the same machinery over their signal subsets (routing null where not attributable).
60 +8. **Velocity / acceleration / volatility** from the last hour of `pressure_history`.
61 +9. **Events** (`engine/events.py`): scope score ≥ 45 with confidence ≥ 0.45 for 2 cycles ⇒ `detected`; → `developing` → `active`
62 + (6 cycles); < 30 ⇒ `recovering`; 10 min under 30 ⇒ `resolved`. Rule-based hypotheses with evidence.
63 +10. **Pressure Fronts** (`engine/fronts.py`): ≥ 3 stressed (probe, target) pairs on a source-region → destination-region corridor
64 + with saturated corridor stress ≥ 35.
65 +
66 +## Ethics & load
67 +
68 +Per probe with 326 targets: ~7 HTTP/s, ~11 DNS/s (4 resolvers / 120 s), ~5 ICMP runs/s, ≤ 1 traceroute in flight (every 15 min
69 +for the 30 `tr: true` targets). Keep-alive disabled (to measure TCP/TLS), body read capped at 16 KiB, no retries, no parallel
70 +checks to one host, intervals never below 10 s. Total ≈ 25 measurements/s/probe → ~17 M rows/day for 8 probes (~1 GB/day
71 +compressed in ClickHouse).
72 +
73 +## Replay
74 +
75 +`ip replay <from> <to> --weights '{…}'` (and `POST /api/admin/replay`) recomputes the global index from stored component
76 +history with alternative weights. Re-deriving components from raw measurements with alternative engine parameters is an
77 +offline job to add when enough history exists (raw is kept 180 days for exactly that purpose).
78 +
79 +## Retention
80 +
81 +measurements 180 d · measurements_1m 1 y · traceroutes 1 y · bgp_events 3 d (1/25 sampled announcements, all withdrawals) ·
82 +bgp_stats_10s ∞ · bgp_origin_1m 30 d · pressure_history ∞ · signal_features 1 y · probe_health 180 d · engine_runs 90 d.
added docs/DEPLOY.md +43 −0
@@ -0,0 +1,43 @@
1 +# Deployment
2 +
3 +## Topology
4 +
5 +| Where | What |
6 +|---|---|
7 +| **BHS64b** (`ssh BHS64b`, ubuntu@51.161.112.66, OVH Beauharnois, 8 c/16 t, 64 GB) | Docker Compose `infra/compose.yml` in `/opt/internetpressure`: edge Caddy :8350 (bound to wg1 **10.67.0.61**), web, api, engine, bgp, corroboration, Postgres 17, ClickHouse 25.8, Redis 7 |
8 +| **BHS64** gateway | `tunnelctl add www.internetpressure.io BHS64b:8350` (+ apex redirect). DNS `A www → 51.161.112.61` (GoDaddy). |
9 +| Probes | `ca-qc-01` M4M36 · `ca-mtl-01` BHS128 · `us-atl-01` m2m16b · `fr-gra-01` R9128 · `ie-dub-01` m1m16 · `tr-ist-01` m4mh · `tr-usk-01` m4mi · `cy-ayn-01` m4mg — LaunchDaemon `io.internetpressure.probe` (macOS) / `ip-probe.service` (Linux) |
10 +
11 +Secrets: `deploy/.env` on the server only (copy in `~/.internetpressure/env.bhs64b` on the laptop). Probe keys live in
12 +production Postgres; sudo passwords of rented Macs in `~/.internetpressure/sudo.txt`.
13 +
14 +## Commands
15 +
16 +```bash
17 +deploy/bin/prep-server.sh # once: wg1 peer to BHS64, ufw, /opt/internetpressure, deploy/.env with random secrets
18 +deploy/bin/deploy.sh # rsync → build images on BHS64b → compose up → migrate + seed → health
19 +deploy/bin/deploy.sh route # (re)create the public route on the gateway
20 +deploy/bin/deploy.sh status|logs [svc]|restart [svc]|migrate|up|build
21 +deploy/bin/probes.sh build # cross-compile the Go agent
22 +deploy/bin/probes.sh keys # probe ids + HMAC keys (from production)
23 +deploy/bin/probes.sh install [id…] # push + install/upgrade the agent on the nodes
24 +deploy/bin/probes.sh status # /healthz of every probe
25 +```
26 +
27 +Admin token: `grep IP_ADMIN_TOKEN ~/.internetpressure/env.bhs64b` → paste in https://www.internetpressure.io/admin.
28 +
29 +## Operations
30 +
31 +- Logs: `deploy/bin/deploy.sh logs engine` (or `api`, `bgp`, `web`, `edge`).
32 +- ClickHouse shell: `ssh BHS64b 'cd /opt/internetpressure && docker compose -f infra/compose.yml --env-file deploy/.env exec clickhouse clickhouse-client -u ip --password "$(grep CLICKHOUSE_PASSWORD deploy/.env | cut -d= -f2)" -d ip'`.
33 +- Postgres: `… exec postgres psql -U ip ip`.
34 +- Config changes (weights…) are done in `/admin → Scoring`; the file `packages/config/pressure.yaml` is the default.
35 +- Adding a probe: `POST /api/admin/probes` (or add to `data/seed/probes.yaml` + `deploy.sh migrate`), then `probes.sh install <id>`.
36 + Add the node to `~/.internetpressure/sudo.txt` if its sudo needs a password.
37 +- Adding targets: `/admin → Targets` or `data/targets/targets.yaml` + `deploy.sh migrate` (seed is idempotent; it does not delete).
38 +- The instrument's own health: `GET /api/v1/status` (`internal_status` ok/degraded/stale) and `/admin → Overview`.
39 +
40 +## Backups
41 +
42 +ClickHouse and Postgres volumes live on BHS64b's RAID-1 NVMe. `deploy/bin/backup.sh` (to add) should `pg_dump` and
43 +`BACKUP TABLE … TO Disk` to BHS128 nightly; `pressure_history` and `bgp_stats_10s` are the irreplaceable tables.
added docs/PROBE-PROTOCOL.md +139 −0
@@ -0,0 +1,139 @@
1 +# Probe ↔ Ingestion protocol (v1)
2 +
3 +The probe agent (Go, `services/probe-agent`) talks only to the ingestion API (`apps/api`, FastAPI) over HTTPS.
4 +Base URL in production: `https://www.internetpressure.io/ingest/v1`. Nothing else is required on the probe side.
5 +
6 +## Authentication — signed requests
7 +
8 +Every probe has a `probe_id` (e.g. `ca-qc-01`) and a 32-byte hex `key` issued once by the admin API. Requests carry:
9 +
10 +| Header | Value |
11 +|---|---|
12 +| `X-IP-Probe` | `probe_id` |
13 +| `X-IP-Timestamp` | Unix seconds (UTC) at the moment of sending |
14 +| `X-IP-Signature` | lowercase hex `HMAC-SHA256(key_bytes, canonical)` |
15 +| `Content-Encoding` | `gzip` on `POST /batch` (body is gzip-compressed JSON) |
16 +| `Content-Type` | `application/json` |
17 +| `User-Agent` | `InternetPressureProbe/<version> (+https://www.internetpressure.io/probes)` |
18 +
19 +`canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + sha256_hex(raw_body_bytes_as_sent)` where PATH is the
20 +request path without query string (e.g. `/ingest/v1/batch`) and the hash is over the compressed bytes for gzip
21 +bodies, or `sha256_hex("")` for GET. `key_bytes` = the hex key decoded to bytes. The server rejects skew > 300 s
22 +and replays (same probe + timestamp + body hash within 10 min).
23 +
24 +## GET /ingest/v1/config
25 +
26 +Returns the probe's assignment. The probe calls it at start-up, then every `schedule.config_refresh_seconds`, and
27 +immediately when a batch response carries a different `config_version`.
28 +
29 +```json
30 +{
31 + "server_time": "2026-09-12T05:10:04.120Z",
32 + "config_version": "2026-09-12T04:00:00Z-7f3a",
33 + "probe": { "probe_id": "ca-qc-01", "name": "Québec City (Bell)", "region": "na-east", "country": "CA",
34 + "city": "Québec", "provider": "Bell Canada", "asn": 577, "lat": 46.8, "lon": -71.2, "enabled": true },
35 + "schedule": { "tiers": {"1": 20, "2": 45, "3": 180}, "dns_every": 60, "ping_every": 30, "traceroute_every": 900,
36 + "batch_flush_seconds": 10, "max_batch": 500, "config_refresh_seconds": 300,
37 + "boost": { "targets": ["aws-us-east-1"], "factor": 0.5, "until": "2026-09-12T05:25:00Z" } },
38 + "resolvers": [ {"id": "system", "address": ""}, {"id": "google", "address": "8.8.8.8:53"},
39 + {"id": "cloudflare", "address": "1.1.1.1:53"}, {"id": "quad9", "address": "9.9.9.9:53"} ],
40 + "targets": [
41 + { "target_id": "cloudflare-www", "name": "Cloudflare", "hostname": "www.cloudflare.com",
42 + "url": "https://www.cloudflare.com/", "ip": null, "port": 443, "category": "cdn", "provider": "cloudflare",
43 + "service_id": "cloudflare", "country": null, "region": "global", "importance": 5, "tier": 1,
44 + "checks": ["http", "dns", "ping"], "traceroute": true }
45 + ]
46 +}
47 +```
48 +
49 +Rules the agent follows:
50 +- `tier` → HTTP check interval `schedule.tiers[tier]` seconds (±10 % jitter, spread uniformly so a probe never bursts).
51 +- `checks` may contain `http`, `dns`, `ping`, `tcp`. `dns` runs against every configured resolver every `dns_every`
52 + seconds; `ping` every `ping_every`; `traceroute: true` targets get one traceroute every `traceroute_every`.
53 +- `boost`: for listed targets multiply intervals by `factor` until `until`.
54 +- A target with `"ip"` set is dialed at that IP (hostname still used for TLS SNI / Host).
55 +- If `probe.enabled` is false the agent idles (health only) and re-checks config.
56 +- Measurements must look like ordinary lightweight client traffic: one request, no retries inside a check, no
57 + parallel hammering of the same host, HTTP body read capped at 16 KiB, keep-alive disabled (so TCP/TLS are measured).
58 +
59 +## POST /ingest/v1/batch
60 +
61 +Gzip-compressed JSON:
62 +
63 +```json
64 +{
65 + "probe_id": "ca-qc-01",
66 + "agent_version": "0.1.0",
67 + "sent_at": "2026-09-12T05:10:14.001Z",
68 + "measurements": [ /* ≤ max_batch */ ],
69 + "traceroutes": [ /* optional */ ],
70 + "health": { /* optional, at most once per minute */ }
71 +}
72 +```
73 +
74 +### Measurement
75 +
76 +```json
77 +{
78 + "ts": "2026-09-12T05:10:04.123Z",
79 + "target_id": "cloudflare-www",
80 + "kind": "http", // "http" | "dns" | "ping" | "tcp"
81 + "ok": true,
82 + "error": "", // "" or short code (see below)
83 + "dns_ms": 12.8, "tcp_ms": 23.1, "tls_ms": 35.4, "ttfb_ms": 79.2, "total_ms": 90.1, // http (null when N/A)
84 + "http_status": 200, "http_proto": "HTTP/2.0", "tls_version": "TLS1.3", // http
85 + "resolved_ip": "104.16.123.96",
86 + "resolver": "cloudflare", "dns_rcode": "NOERROR", "dns_answers": ["104.16.123.96","104.16.124.96"], // dns
87 + "sent": 5, "received": 5, "packet_loss": 0.0, "rtt_min_ms": 11.9, "rtt_avg_ms": 12.4, "rtt_max_ms": 13.0, "jitter_ms": 0.4 // ping/tcp
88 +}
89 +```
90 +
91 +Only the fields relevant to `kind` need to be present; the others may be omitted or null. Timestamps are UTC RFC 3339
92 +with milliseconds. `packet_loss` is 0–1.
93 +
94 +Error codes: `dns_fail`, `dns_timeout`, `dns_servfail`, `dns_nxdomain`, `tcp_timeout`, `tcp_refused`, `tcp_reset`,
95 +`tls_fail`, `tls_cert`, `http_timeout`, `http_5xx`, `http_4xx`, `reset`, `unreachable`, `icmp_unavailable`, `other`.
96 +`ok` for `http` means: connection + TLS succeeded and status < 500. `ok` for `dns` means rcode NOERROR with ≥1 answer.
97 +`ok` for `ping`/`tcp` means received ≥ 1.
98 +
99 +### Traceroute
100 +
101 +```json
102 +{ "ts": "…", "target_id": "cloudflare-www", "dest_ip": "104.16.123.96", "reached": true, "hop_count": 11,
103 + "total_ms": 30.2, "route_hash": "3f1c…", // sha1 over "ip1|ip2|*|ip4…" (unanswered hops kept as *)
104 + "hops": [ {"n": 1, "ip": "192.168.2.1", "rtt_ms": 1.2}, {"n": 2, "ip": "*", "rtt_ms": null} ] }
105 +```
106 +
107 +### Health
108 +
109 +```json
110 +{ "ts": "…", "agent_version": "0.1.0", "uptime_s": 86400, "buffered": 0, "spool_bytes": 0,
111 + "measurements_total": 120345, "errors_total": 12, "clock_offset_ms": -14, "rss_mb": 21.4, "goroutines": 18,
112 + "capabilities": ["http", "dns", "ping", "traceroute"], "os": "darwin", "arch": "arm64",
113 + "identity": { "public_ip": "76.70.60.50", "asn": 577, "org": "Bell Canada", "country": "CA", "city": "Québec",
114 + "lat": 46.79, "lon": -71.35, "source": "ipinfo.io" } }
115 +```
116 +
117 +`identity` is best-effort (ipinfo.io / ip-api.com, refreshed hourly). Only the public IP, ASN and city-level
118 +coordinates are ever sent — never a street address. The server stores it on the probe record and displays
119 +city-level location only. `clock_offset_ms` = local clock − server clock, estimated from `server_time` in responses
120 +(half RTT correction).
121 +
122 +### Response
123 +
124 +```json
125 +{ "accepted": 231, "rejected": 0, "config_version": "…", "server_time": "…", "boost": { … } }
126 +```
127 +
128 +`4xx` on auth/signature → do not retry the same batch (it is spooled and re-sent with a fresh signature only if the
129 +failure was `401 skew`). `5xx`/network error → spool to disk, exponential backoff 5 s → 5 min, retry oldest first.
130 +
131 +## GET /ingest/v1/agent/latest
132 +
133 +`{ "version": "0.1.3", "assets": { "darwin-arm64": { "url": "…", "sha256": "…" }, "linux-amd64": { … } } }`
134 +— optional self-update: download, verify, atomically replace the binary, exit 0 (the supervisor restarts it).
135 +
136 +## Local health endpoint (on the probe)
137 +
138 +`http://127.0.0.1:9381/healthz` → `{"ok":true,"probe_id":…,"buffered":…,"last_flush":…}` and `/metrics`
139 +(Prometheus text). Not exposed publicly.
added docs/SPEC.md +161 −0
@@ -0,0 +1,161 @@
1 +# InternetPressure.io — product specification (authoritative, from the founder, 2026-09-12)
2 +
3 +Tagline: **The real-time pressure gauge for the Internet.**
4 +
5 +InternetPressure.io is a global, real-time Internet observability platform that measures the current "pressure" of the
6 +public Internet: a continuously changing numerical representation of how stressed, unstable, congested, degraded,
7 +fragmented or abnormal the global Internet currently is. Not another uptime monitor, status aggregator or threat
8 +dashboard. The system observes the Internet independently with our own distributed measurement infrastructure and
9 +open public telemetry; commercial APIs must never be a critical dependency. The long-term asset is the proprietary
10 +historical dataset generated by the InternetPressure Observability Network.
11 +
12 +## 1. Vision
13 +A weather service for the Internet: pressure, baselines, storm systems (Pressure Fronts), regional maps, forecasts.
14 +Users must immediately understand whether the Internet is behaving normally, where stress is increasing, which
15 +regions/providers are affected, whether routing, latency, DNS, availability or paths are abnormal, and whether an
16 +anomaly is local, regional, provider-specific or global. The interface feels alive (Bloomberg terminal / flight radar
17 +/ weather radar / NOC), not like a monitoring SaaS.
18 +
19 +## 2. Core principle — data hierarchy
20 +1. our own direct measurements · 2. open public Internet telemetry · 3. public raw feeds · 4. polling public
21 +infrastructure information · 5. external APIs only as optional corroboration. Target: 70–80 % proprietary, 15–25 %
22 +open feeds, < 10 % external enrichment. Must remain useful if every commercial API disappears.
23 +
24 +## 3. Primary metric — Global Internet Pressure Index (0–100)
25 +0–10 exceptionally calm · 10–25 normal · 25–40 elevated · 40–55 stressed · 55–70 highly stressed · 70–85 severe
26 +disruption · 85–100 extreme Internet event. A composite observability index, never "scientific truth"; every score
27 +explainable (click → causes).
28 +
29 +## 4. Components
30 +- **Routing**: BGP announcements/withdrawals per second, churn, origin changes, visibility loss, path instability,
31 + leaks/hijack suspicion. Sources: RIPE RIS Live, RouteViews, CAIDA BGPStream, MRT dumps. Store raw where practical.
32 +- **Latency** (our probes): ICMP RTT, TCP connect, TLS handshake, HTTP TTFB, inter-region latency, loss, jitter —
33 + always relative to baseline (20 → 100 ms matters; 100 ms alone does not).
34 +- **DNS**: lookup latency, SERVFAIL/timeouts, NXDOMAIN anomalies, DNSSEC, resolver disagreement, authoritative
35 + failures, root/TLD anomalies. Probe local, Google, Cloudflare, Quad9, ISP, direct authoritative. No abusive volume.
36 +- **Availability**: representative endpoints across cloud, major services, CDNs, DNS, developer platforms, social,
37 + search, finance, government, communication, streaming, commerce. Multi-probe corroboration required.
38 +- **HTTP/TLS**: failures, 5xx, TLS/cert failures, timeouts, resets, protocol downgrade, HTTP/2–3 availability.
39 +- **Path**: sampled traceroutes → route hashes, ASN path, hop count, transit changes, rerouting, latency shifts.
40 +- **Infrastructure** (later): cloud region incidents, transit issues, submarine cables, IX congestion, CDN disruptions.
41 +
42 +## 5–6. Score design & normalisation
43 +Provisional weights routing 0.25 · latency 0.20 · dns 0.15 · availability 0.15 · http_tls 0.10 · path 0.10 ·
44 +corroboration 0.05 — **configuration-driven, never hard-coded**. Per signal keep rolling median/mean/std/MAD,
45 +percentiles, hourly & weekday seasonality, regional baseline. Preferred anomaly: `robust_z = (x − median) / MAD`,
46 +winsorised/clipped. Normal daily patterns are not pressure.
47 +
48 +## 7–8. Dynamic weighting & confidence
49 +Network Importance Score (ASN centrality, prefixes, downstream dependency, known services, coverage, transit/cloud/CDN
50 +relevance): `event_pressure = anomaly_strength × affected_scope × network_importance × confidence`. Every incident has a
51 +confidence (probes, geo diversity, signal agreement, BGP corroboration, magnitude, external corroboration, duration).
52 +Language: "Potential degradation", "Probable regional routing issue", "High-confidence DNS disruption".
53 +
54 +## 9–14. Observability network, probe agent, measurements, targets, scheduler, ethics
55 +Own probe network (MacLustr Québec, OVH Québec/Canada/France, cheap VPS; later 18+ world regions). Agent in **Go**
56 +(Rust alternative; not Python): lightweight, low RAM, safe restart/reconnect, self-update, buffering, health metrics,
57 +region/ISP/ASN identity, clock sync, signed telemetry; `probe_id, region, country, provider, ASN, lat/lon approx,
58 +version, capabilities`; never precise private addresses. Batched, compressed payloads. Central target registry in
59 +DB/config (categories DNS, CDN, Cloud, Search, Messaging, Social, Finance, Government, News, Developer, AI, Streaming,
60 +Commerce, Infrastructure) with importance, frequency, protocols. Adaptive scheduler: tier 1 15–30 s, tier 2 30–60 s,
61 +tier 3 2–5 min, traceroute 5–30 min, deep checks hourly; boost sampling during anomalies. Ethics: no scanning, no auth
62 +bypass, no private infra, no exploitation, no excessive traffic, no rate-limit evasion, no personal data — traffic
63 +resembles a normal lightweight client.
64 +
65 +## 15–16. BGP ingestion & features
66 +`bgp-ingestor`: RIS Live (+ RouteViews/BGPStream), normalise, dedupe, prefix→ASN, aggregates, anomaly features. Event
67 +schema: timestamp, collector, peer_asn, prefix, origin_asn, event_type, as_path, community, next_hop, source. Raw kept
68 +temporarily, aggregates indefinitely. Features: announcements/s, withdrawals/s, unique prefixes/ASNs changed, origin
69 +changes, churn, path-length change, path entropy, visibility, collector disagreement; baselines by minute/hour/day/
70 +weekday/region/ASN/prefix.
71 +
72 +## 17–20. Detection engine, events, hypotheses, Pressure Fronts
73 +`pressure-engine` every 5–15 s: baselines, anomaly strength, component/regional/ASN/service/global scores, confidence,
74 +incident candidates. Movement only from measurements — never randomised. Events with states detected → developing →
75 +active → recovering → resolved, evolving over time. Causal hypotheses with evidence, never overstated. **Pressure
76 +Fronts**: connected geographic/network regions simultaneously rising (e.g. "North Atlantic Pressure Front, intensity
77 +74, direction East, North America → Western Europe"), drawn on the map — the signature visual feature.
78 +
79 +## 21–22. Storage & retention
80 +PostgreSQL (registry, config, incidents, ASN/region metadata) · ClickHouse (measurements, telemetry, BGP aggregates,
81 +pressure history — primary analytical store) · Redis (latest values, live state, cache, pub/sub) · object storage
82 +(MinIO/S3) for raw archives/exports/backups. Retention: raw probes 90–180 d; 1-min 1 y; 5-min 3 y; hourly forever;
83 +BGP raw selective; aggregates and pressure history indefinite.
84 +
85 +## 23–25. Architecture & stacks
86 +Probes → Ingestion API → ClickHouse/Redis/Postgres → Pressure Engine → Event Engine + Live State → Public API →
87 +Frontend. BGP: RIS/RouteViews → ingestor → normaliser → feature engine → pressure engine. Monorepo apps/ services/
88 +packages/ infra/ data/ docs/. Frontend: Next.js, TypeScript, React, Tailwind, MapLibre GL, ECharts (or lightweight),
89 +WebSocket/SSE. Few dependencies, no SaaS-template look.
90 +
91 +## 26–36. Design & pages
92 +Dark-first, technical, restrained, premium, dense but readable, minimal cards, data is the design, animated values,
93 +strong typography, subtle grid, smooth real-time transitions. Avoid huge rounded boxes, generic gradients,
94 +glassmorphism, childish icons, oversized marketing, fake visualisations. **Homepage is the product**: above the fold
95 +"GLOBAL INTERNET PRESSURE 42.7 ELEVATED +6.3 / 1h", components list, live world map; live ticker (BGP updates/s,
96 +withdrawals/s, probes active, measurements/s, targets degraded, regions elevated, DNS failures/min, median RTT,
97 +route changes/min, active incidents) from real streams. Map modes: pressure, latency, loss, DNS, routing,
98 +availability, incidents, probe network; drill-down world → continent → country → metro → probe → ASN without
99 +pretending precision. Regional pages `/internet/<region>`, `/country/<cc>`; ASN pages `/asn/<n>`; service pages
100 +`/service/<slug>` showing independent observation vs vendor status (the discrepancy is valuable); route explorer
101 +(probe → ISP → transit → destination; normal vs current; added/removed hops, ASN changes, latency shifts); incident
102 +pages `/event/<slug>` kept forever; `/history`, `/history/<year>`, `/history/<year>/<month>`; Global Internet Clock
103 +("Right now: 28,412 probe measurements/min, 8,441 BGP updates/min, 17 regions normal, 3 elevated, 0 severe").
104 +
105 +## 37–39. API, live stream, admin
106 +Public API `/api/v1/pressure/global|country/<cc>|asn/<n>`, `/incidents`, `/bgp/stats`, `/latency` (rate-limited free
107 +tier; the frontend uses the same API). Live `/api/v1/live` (SSE preferred): global/regional updates, incidents, bgp
108 +stats, probe stats, service degradation. `/admin` (separate from public UI): probe health, targets & frequencies,
109 +regions, incident review, scoring/weight config, baseline diagnostics, pipeline health, BGP collector health, storage,
110 +raw event explorer, manual annotations.
111 +
112 +## 40–45. Data quality, external sources, connectors, provenance, explainability, correlation
113 +Track probe uptime, clock drift, missing measurements, error rate, version; per signal sample size, coverage,
114 +collector count, last update, confidence — no strong conclusions from weak coverage. External sources optional
115 +(RIPE, RouteViews, CAIDA, ISP/cloud status pages, Cloudflare Radar, IXP stats, cable announcements): independent
116 +connectors behind a common interface, cached raw data, provenance stored, graceful disappearance, licences respected,
117 +no business logic in connectors. Provenance for every derived metric (source, ts, id, raw, normalised, baseline,
118 +anomaly, contribution) so "Why is Global Pressure 67?" is answerable: "+14 from BGP churn, +11 from NA packet loss, +8
119 +DNS failures, +6 AWS endpoint degradation, −3 Europe stable". Rule-based correlation first (BGP spike + latency +
120 +path change + failures ⇒ higher confidence); ML later; no unnecessary AI.
121 +
122 +## 46–49. AI, forecast, velocity, stability
123 +AI only for grounded summaries/explanations/parsing/ranking; never fabricated measurements. Forecast (30-min,
124 +severe-risk %) only once enough history exists. Compute pressure, velocity (/h), acceleration (/h²), volatility to
125 +distinguish "high but recovering" from "moderate but worsening". Optional inverse Internet Stability index (not MVP).
126 +
127 +## 50–57. Mobile, performance, SEO, security, deployment, domain, self-observability, self-exclusion
128 +Dedicated mobile layouts (big number, swipeable components, map, incidents, ticker). LCP < 2 s, CLS ≈ 0, partial
129 +re-rendering, lazy map, server-side aggregation (never millions of points). Indexable country/ASN/service/incident/
130 +history pages; programmatic SEO only with real data. Security: strict validation, TLS, probe auth + signed telemetry,
131 +rate limiting, admin MFA (later), secret separation, no secrets in Git, isolated DB creds, allowlists, key rotation,
132 +audit logs; ingestion assumes hostile traffic. Deployment: MacLustr environment, Docker Compose (web, api, ingestor,
133 +engine, bgp, postgres, clickhouse, redis, minio, nginx/caddy), auto-restart. Domain https://www.internetpressure.io
134 +(canonical, apex redirects), HTTPS only. Monitor ourselves (API/ingest latency, queues, probe count/freshness, CH
135 +insert rate, Redis, BGP freshness, engine time, frontend errors). **Self-exclusion: our own infrastructure failure must
136 +never be read as an Internet outage.**
137 +
138 +## 58–60. Phases
139 +**MVP**: 4–8 probes, 100–300 targets, RIS ingestion, latency/DNS/HTTP/basic traceroute, ClickHouse, baseline engine,
140 +global + regional pressure, live homepage, map, incident detection, admin health — no auth/billing/enterprise.
141 +**Phase 2**: 15–25 probes, RouteViews, ASN/service/country pages, richer BGP, path comparison, Pressure Fronts,
142 +history explorer, alerting, API. **Phase 3**: 50+ probes, forecasting, event classification, ISP benchmarking, cloud
143 +route observability, cable correlation, stability rankings, enterprise data.
144 +
145 +## 61–74. Business, differentiation, language, philosophy
146 +Public dashboard stays accessible; later revenue from historical/high-res data, enterprise feeds, webhooks, ASN
147 +monitoring, benchmarking, research datasets. Not Downdetector/Radar/RIPEstat/Pingdom/ThousandEyes/Grafana/Statuspage:
148 +we synthesise independent telemetry into a continuously changing measure of global Internet stress. Vocabulary:
149 +Pressure, Elevated, Rising, Falling, Stable, Developing, Recovering, Routing instability, Latency anomaly, Regional
150 +degradation, Observed disruption, Pressure Front — never "Internet collapse/apocalypse/massive outage" without
151 +evidence. Every chart answers: what changed, where, how large, since when, which signals, what cause, better or worse.
152 +**No fake real-time**: never animate random numbers or synthesise events; if one real update arrives per 30 s, update
153 +every 30 s. UTC storage, local display with UTC toggle, deterministic timelines. Development: inspect before changing,
154 +reuse, modular services, migrations, tests for scoring (unit: normalisation, weights, anomaly, confidence, correlation;
155 +integration: ingest, BGP, ClickHouse, Redis, API; failure: probe/BGP/Redis offline, partial DB outage, region
156 +unavailable, clock skew, duplicates), typed interfaces, documented env vars, no hard-coded secrets, reproducible
157 +deploys, replay capability to validate scoring on historical incidents. Success = a knowledgeable user sees pressure
158 +rise 27 → 61, inspects the evidence and concludes something real is happening. Execution order: repo → Docker → stores
159 +→ probe registry → target registry → Go agent → ingestion → storage → baselines → latency/DNS/availability scores →
160 +RIS ingestion → routing score → global engine → SSE → homepage → live metrics → map → regional scores → event engine →
161 +admin. **An instrument, not a website**: leave it open on a NOC screen and watch the Internet change.
added infra/clickhouse/config.d/ip.xml +14 −0
@@ -0,0 +1,14 @@
1 +<clickhouse>
2 + <!-- InternetPressure: modest footprint, no interserver, quiet logs -->
3 + <logger>
4 + <level>warning</level>
5 + <console>1</console>
6 + </logger>
7 + <max_server_memory_usage_to_ram_ratio>0.8</max_server_memory_usage_to_ram_ratio>
8 + <mark_cache_size>536870912</mark_cache_size>
9 + <listen_host>0.0.0.0</listen_host>
10 + <timezone>UTC</timezone>
11 + <merge_tree>
12 + <ttl_only_drop_parts>1</ttl_only_drop_parts>
13 + </merge_tree>
14 +</clickhouse>
added infra/compose.dev.yml +21 −0
@@ -0,0 +1,21 @@
1 +# Dev stores only (laptop): docker compose -f infra/compose.dev.yml up -d
2 +# Postgres → 127.0.0.1:5435 · ClickHouse HTTP → 127.0.0.1:8124 · Redis → 127.0.0.1:6380 (defaults in settings.py)
3 +name: ip-dev
4 +services:
5 + postgres:
6 + image: postgres:17-alpine
7 + environment: { POSTGRES_DB: ip, POSTGRES_USER: ip, POSTGRES_PASSWORD: ip, TZ: UTC }
8 + ports: ["127.0.0.1:5435:5432"]
9 + volumes: [pg-dev:/var/lib/postgresql/data]
10 + clickhouse:
11 + image: clickhouse/clickhouse-server:25.8
12 + environment: { CLICKHOUSE_DB: ip, CLICKHOUSE_USER: ip, CLICKHOUSE_PASSWORD: ip, CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1", TZ: UTC }
13 + ulimits: { nofile: { soft: 262144, hard: 262144 } }
14 + ports: ["127.0.0.1:8124:8123", "127.0.0.1:9011:9000"]
15 + volumes: [ch-dev:/var/lib/clickhouse, ./clickhouse/config.d:/etc/clickhouse-server/config.d:ro]
16 + redis:
17 + image: redis:7-alpine
18 + ports: ["127.0.0.1:6380:6379"]
19 +volumes:
20 + pg-dev:
21 + ch-dev:
added infra/compose.yml +201 −0
@@ -0,0 +1,201 @@
1 +# InternetPressure.io — production stack (BHS64b, OVH Beauharnois) and dev stores.
2 +#
3 +# prod: docker compose -f infra/compose.yml --env-file deploy/.env up -d --remove-orphans
4 +# dev: docker compose -f infra/compose.yml --profile dev up -d clickhouse postgres redis (stores only, host ports 5433/8124/6380)
5 +#
6 +# Public traffic arrives from the BHS64 gateway over WireGuard wg1: edge Caddy :8350 is bound to ${WG_IP} only.
7 +# Nothing is bound to 0.0.0.0 in production. The stores are only reachable from the compose network.
8 +name: ip
9 +
10 +x-logging: &logging
11 + driver: json-file
12 + options: { max-size: "50m", max-file: "5" }
13 +
14 +x-app: &app
15 + image: ip/backend:${IP_TAG:-latest}
16 + build:
17 + context: ..
18 + dockerfile: infra/docker/Dockerfile.backend
19 + restart: unless-stopped
20 + logging: *logging
21 + environment: &app-env
22 + IP_ENV: production
23 + IP_LOG_LEVEL: ${IP_LOG_LEVEL:-info}
24 + IP_SITE_URL: ${IP_SITE_URL:-https://www.internetpressure.io}
25 + IP_PG_DSN: postgresql://ip:${POSTGRES_PASSWORD}@postgres:5432/ip
26 + IP_CH_URL: http://clickhouse:8123
27 + IP_CH_DB: ip
28 + IP_CH_USER: ip
29 + IP_CH_PASSWORD: ${CLICKHOUSE_PASSWORD}
30 + IP_REDIS_URL: redis://redis:6379/0
31 + IP_ADMIN_TOKEN: ${IP_ADMIN_TOKEN}
32 + IP_TRUST_PROXY: "true"
33 + IP_CONFIG_PATH: /app/packages/config/pressure.yaml
34 + IP_REGIONS_PATH: /app/data/regions.yaml
35 + IP_TARGETS_PATH: /app/data/targets/targets.yaml
36 + IP_SERVICES_PATH: /app/data/seed/services.yaml
37 + IP_PROBES_PATH: /app/data/seed/probes.yaml
38 + IP_DATA_DIR: /var/lib/ip
39 + IP_RELEASES_DIR: /releases
40 + IP_RIS_COLLECTORS: ${IP_RIS_COLLECTORS:-}
41 + IP_BGP_STORE_RAW: ${IP_BGP_STORE_RAW:-true}
42 + TZ: UTC
43 + volumes:
44 + - ip-data:/var/lib/ip
45 + - ./releases:/releases:ro
46 + depends_on:
47 + postgres: { condition: service_healthy }
48 + clickhouse: { condition: service_healthy }
49 + redis: { condition: service_healthy }
50 +
51 +services:
52 + # ───────────────────────────── edge (the only thing the gateway talks to)
53 + edge:
54 + image: caddy:2.10-alpine
55 + restart: unless-stopped
56 + logging: *logging
57 + ports:
58 + - "${WG_IP:-127.0.0.1}:8350:8350"
59 + volumes:
60 + - ./edge/Caddyfile:/etc/caddy/Caddyfile:ro
61 + - caddy-data:/data
62 + depends_on: [web, api]
63 + healthcheck:
64 + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8350/api/v1/status"]
65 + interval: 30s
66 + timeout: 5s
67 + retries: 3
68 + start_period: 30s
69 + mem_limit: 256m
70 +
71 + # ───────────────────────────── application
72 + web:
73 + image: ip/web:${IP_TAG:-latest}
74 + build:
75 + context: ..
76 + dockerfile: apps/web/Dockerfile
77 + args:
78 + NEXT_PUBLIC_SITE_URL: ${IP_SITE_URL:-https://www.internetpressure.io}
79 + restart: unless-stopped
80 + logging: *logging
81 + environment:
82 + NODE_ENV: production
83 + PORT: "8351"
84 + HOSTNAME: 0.0.0.0
85 + API_URL: http://api:8352
86 + API_URL_INTERNAL: http://api:8352
87 + NEXT_PUBLIC_SITE_URL: ${IP_SITE_URL:-https://www.internetpressure.io}
88 + TZ: UTC
89 + depends_on: [api]
90 + mem_limit: 2g
91 +
92 + api:
93 + <<: *app
94 + command: ["ip", "api"]
95 + environment:
96 + <<: *app-env
97 + IP_API_PORT: "8352"
98 + healthcheck:
99 + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8352/api/v1/health', timeout=3).status==200 else 1)"]
100 + interval: 20s
101 + timeout: 5s
102 + retries: 3
103 + start_period: 40s
104 + mem_limit: 2g
105 +
106 + engine:
107 + <<: *app
108 + command: ["ip", "engine"]
109 + mem_limit: 2g
110 +
111 + bgp:
112 + <<: *app
113 + command: ["ip", "bgp"]
114 + mem_limit: 1g
115 +
116 + corroboration:
117 + <<: *app
118 + command: ["ip", "corroboration"]
119 + mem_limit: 512m
120 +
121 + migrate:
122 + <<: *app
123 + command: ["sh", "-c", "ip migrate && ip seed"]
124 + restart: "no"
125 + profiles: [ops]
126 +
127 + cli:
128 + <<: *app
129 + entrypoint: ["ip"]
130 + restart: "no"
131 + profiles: [ops]
132 +
133 + # ───────────────────────────── stores
134 + postgres:
135 + image: postgres:17-alpine
136 + restart: unless-stopped
137 + logging: *logging
138 + environment:
139 + POSTGRES_DB: ip
140 + POSTGRES_USER: ip
141 + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ip}
142 + TZ: UTC
143 + volumes:
144 + - pg-data:/var/lib/postgresql/data
145 + healthcheck:
146 + test: ["CMD-SHELL", "pg_isready -U ip -d ip"]
147 + interval: 10s
148 + timeout: 5s
149 + retries: 6
150 + mem_limit: 2g
151 +
152 + clickhouse:
153 + image: clickhouse/clickhouse-server:25.8
154 + restart: unless-stopped
155 + logging: *logging
156 + ulimits:
157 + nofile: { soft: 262144, hard: 262144 }
158 + environment:
159 + CLICKHOUSE_DB: ip
160 + CLICKHOUSE_USER: ip
161 + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-ip}
162 + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
163 + TZ: UTC
164 + volumes:
165 + - ch-data:/var/lib/clickhouse
166 + - ./clickhouse/config.d:/etc/clickhouse-server/config.d:ro
167 + healthcheck:
168 + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:8123/ping"]
169 + interval: 10s
170 + timeout: 5s
171 + retries: 10
172 + start_period: 20s
173 + mem_limit: ${CLICKHOUSE_MEM:-12g}
174 +
175 + redis:
176 + image: redis:7-alpine
177 + restart: unless-stopped
178 + logging: *logging
179 + command: ["redis-server", "--save", "60", "1000", "--appendonly", "no", "--maxmemory", "512mb", "--maxmemory-policy", "allkeys-lru"]
180 + volumes:
181 + - redis-data:/data
182 + healthcheck:
183 + test: ["CMD", "redis-cli", "ping"]
184 + interval: 10s
185 + timeout: 3s
186 + retries: 5
187 + mem_limit: 768m
188 +
189 + # dev-only host port publication of the stores (profile "dev")
190 + dev-ports:
191 + image: alpine/socat:1.8.0.0
192 + profiles: [dev]
193 + command: ["-d", "TCP-LISTEN:1,fork,reuseaddr", "TCP:localhost:1"]
194 + network_mode: "service:postgres"
195 +
196 +volumes:
197 + pg-data:
198 + ch-data:
199 + redis-data:
200 + ip-data:
201 + caddy-data:
added infra/docker/Dockerfile.backend +22 −0
@@ -0,0 +1,22 @@
1 +# InternetPressure backend image: api · engine · bgp · corroboration · migrate/cli (same image, different command).
2 +FROM python:3.12-slim AS base
3 +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1 UV_SYSTEM_PYTHON=1
4 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* \
5 + && pip install --no-cache-dir uv
6 +
7 +WORKDIR /app
8 +COPY apps/api/pyproject.toml apps/api/README.md* /app/apps/api/
9 +COPY apps/api/src /app/apps/api/src
10 +RUN cd /app/apps/api && uv pip install --system --no-cache .
11 +
12 +# runtime configuration & seeds are read from the repo layout (paths in compose.yml)
13 +COPY packages/config /app/packages/config
14 +COPY data/regions.yaml /app/data/regions.yaml
15 +COPY data/targets /app/data/targets
16 +COPY data/seed /app/data/seed
17 +
18 +RUN useradd -r -u 10001 -d /var/lib/ip ip && mkdir -p /var/lib/ip && chown ip:ip /var/lib/ip
19 +USER ip
20 +ENV IP_DATA_DIR=/var/lib/ip
21 +EXPOSE 8352
22 +CMD ["ip", "api"]
added infra/edge/Caddyfile +48 −0
@@ -0,0 +1,48 @@
1 +# InternetPressure edge (BHS64b). Plain HTTP on :8350, published ONLY on the WireGuard address (compose.yml).
2 +# TLS terminates on the BHS64 gateway (https://www.internetpressure.io → 10.67.0.61:8350), the trusted proxy.
3 +{
4 + admin off
5 + auto_https off
6 + servers {
7 + trusted_proxies static 10.67.0.0/24 172.16.0.0/12
8 + }
9 + log {
10 + output stdout
11 + format json
12 + level INFO
13 + }
14 +}
15 +
16 +:8350 {
17 + encode zstd gzip
18 +
19 + header {
20 + -Server
21 + X-Content-Type-Options nosniff
22 + X-Frame-Options SAMEORIGIN
23 + Referrer-Policy strict-origin-when-cross-origin
24 + Permissions-Policy "camera=(), microphone=(), geolocation=()"
25 + Strict-Transport-Security "max-age=63072000; includeSubDomains"
26 + defer
27 + }
28 +
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 {
32 + reverse_proxy api:8352 {
33 + flush_interval -1
34 + transport http {
35 + read_timeout 0
36 + dial_timeout 5s
37 + }
38 + header_up X-Forwarded-Proto https
39 + }
40 + }
41 +
42 + # everything else → Next.js
43 + handle {
44 + reverse_proxy web:8351 {
45 + header_up X-Forwarded-Proto https
46 + }
47 + }
48 +}
added packages/config/pressure.yaml +111 −0
@@ -0,0 +1,111 @@
1 +# InternetPressure.io — scoring configuration (single source of truth for the pressure engine).
2 +# Everything here is hot-reloadable by the engine (re-read every cycle) and editable from /admin.
3 +# Weights must sum to 1.0. Nothing in the application code hard-codes these values.
4 +
5 +version: 1
6 +
7 +pressure_weights:
8 + routing: 0.25
9 + latency: 0.20
10 + dns: 0.15
11 + availability: 0.15
12 + http_tls: 0.10
13 + path: 0.10
14 + corroboration: 0.05
15 +
16 +levels:
17 + - { max: 10, id: calm, label: "Exceptionally calm" }
18 + - { max: 25, id: normal, label: "Normal" }
19 + - { max: 40, id: elevated, label: "Elevated" }
20 + - { max: 55, id: stressed, label: "Stressed" }
21 + - { max: 70, id: high, label: "Highly stressed" }
22 + - { max: 85, id: severe, label: "Severe disruption" }
23 + - { max: 100, id: extreme, label: "Extreme Internet event" }
24 +
25 +engine:
26 + cycle_seconds: 10 # how often the global score is recomputed
27 + window_seconds: 120 # "current" window for probe signals
28 + bgp_window_seconds: 60 # "current" window for BGP rates
29 + baseline_days: 7 # trailing baseline horizon
30 + baseline_exclude_seconds: 600 # most recent data excluded from the baseline (so an incident doesn't baseline itself)
31 + baseline_min_samples: 12 # 5-minute buckets; below this the signal is "weak coverage" and its weight is damped (0 under 20 %)
32 + seasonality: hour_of_day # baseline restricted to ±1h same hour of day when enough history exists
33 + seasonality_min_days: 3 # …otherwise plain trailing window
34 + z_clip_low: -3.0
35 + z_clip_high: 8.0
36 + z_anomaly: 3.0 # a pair (probe,target) is abnormal above this robust z
37 + saturation_k: 1.2 # score = 100 * (1 - exp(-k * stress)) / (1 - exp(-k)) — concavity of stress → score
38 + z_stress_start: 1.0 # pair stress ramps from 0 at this robust z …
39 + z_stress_full: 6.0 # … to 1 at this robust z
40 + availability_amplification: 8 # share of importance-weighted targets down × this = availability stress (1/8 down → 100)
41 + rate_scale: 0.25 # failure-rate excess over baseline that yields full stress (25 % of checks)
42 + loss_scale: 0.10 # packet-loss excess over baseline that yields full stress (10 %)
43 + min_probes_for_scoring: 2 # self-exclusion: below this the engine freezes and reports internal degradation
44 + probe_fresh_seconds: 180 # a probe is "fresh" if we received a batch in the last N seconds
45 + probe_local_failure_ratio: 0.8 # if ≥80 % of a probe's targets fail at once, the probe is excluded (its own uplink is down)
46 + bgp_fresh_seconds: 120
47 +
48 +# Per-component stress recipe. Each signal contributes weight × f(robust_z or ratio) to the component "stress",
49 +# which is then saturated into 0–100. The `label` is what the explainability UI shows.
50 +components:
51 + latency:
52 + signals:
53 + - { id: ttfb_z, label: "HTTP time-to-first-byte vs baseline", weight: 0.35 }
54 + - { id: tcp_z, label: "TCP connect latency vs baseline", weight: 0.25 }
55 + - { id: rtt_z, label: "ICMP round-trip time vs baseline", weight: 0.25 }
56 + - { id: loss, label: "Packet loss", weight: 0.15 }
57 + dns:
58 + signals:
59 + - { id: dns_fail_rate, label: "DNS SERVFAIL / timeout rate", weight: 0.45 }
60 + - { id: dns_latency_z, label: "DNS lookup latency vs baseline", weight: 0.30 }
61 + - { id: resolver_disagreement, label: "Resolver disagreement", weight: 0.25 }
62 + availability:
63 + signals:
64 + - { id: target_down_corroborated, label: "Targets failing from ≥2 probe regions", weight: 0.70 }
65 + - { id: fail_rate_z, label: "Failure rate vs baseline", weight: 0.30 }
66 + http_tls:
67 + signals:
68 + - { id: http_5xx_rate, label: "HTTP 5xx rate", weight: 0.35 }
69 + - { id: tls_fail_rate, label: "TLS handshake failures", weight: 0.35 }
70 + - { id: reset_timeout_rate, label: "Connection resets / timeouts", weight: 0.30 }
71 + path:
72 + signals:
73 + - { id: route_change_rate, label: "Route fingerprint changes vs baseline churn", weight: 0.60 }
74 + - { id: hop_count_z, label: "Hop count deviation", weight: 0.20 }
75 + - { id: path_latency_shift, label: "Latency shift on changed paths", weight: 0.20 }
76 + routing:
77 + signals:
78 + - { id: bgp_withdrawals_z, label: "BGP withdrawals/s vs baseline", weight: 0.40 }
79 + - { id: bgp_announcements_z, label: "BGP announcements/s vs baseline", weight: 0.25 }
80 + - { id: bgp_origin_changes_z, label: "Origin ASN changes vs baseline", weight: 0.20 }
81 + - { id: bgp_collector_disagreement, label: "Collector disagreement", weight: 0.15 }
82 + corroboration:
83 + signals:
84 + - { id: vendor_incidents, label: "Public incidents declared by major providers", weight: 1.0 }
85 +
86 +# Network importance multipliers (target importance 1–5 → weight in aggregations).
87 +importance_weights: { 1: 0.4, 2: 0.7, 3: 1.0, 4: 1.5, 5: 2.2 }
88 +
89 +events:
90 + detect_threshold: 45 # component/regional score that opens an event candidate
91 + confirm_cycles: 2 # consecutive cycles above threshold before "detected" → "developing"
92 + active_cycles: 6 # …before "active"
93 + recover_threshold: 30 # below this the event is "recovering"
94 + resolve_after_seconds: 600 # continuous time below recover_threshold before "resolved"
95 + min_confidence: 0.45
96 +
97 +fronts:
98 + min_pairs: 3 # source-region → destination-region pairs elevated simultaneously
99 + z_threshold: 2.5
100 + min_intensity: 35
101 +
102 +scheduler:
103 + tiers: { 1: 20, 2: 45, 3: 180 } # seconds between HTTP checks per tier
104 + dns_every: 120
105 + ping_every: 60
106 + traceroute_every: 900
107 + boost_factor: 0.5 # during anomalies, intervals are multiplied by this for affected targets
108 + boost_seconds: 900
109 + batch_flush_seconds: 10
110 + max_batch: 500
111 + config_refresh_seconds: 300
added services/probe-agent/Makefile +58 −0
@@ -0,0 +1,58 @@
1 +# InternetPressure.io probe agent — build / test / lint
2 +#
3 +# make build → dist/ip-probe-darwin-arm64, dist/ip-probe-linux-amd64 (static, stripped, version-stamped)
4 +# make test → go test ./...
5 +# make lint → gofmt check + go vet
6 +# make run-dev → build for the host and run with probe.dev.yaml (create it from probe.example.yaml)
7 +# make once T=www.cloudflare.com → one-shot checks against a target
8 +
9 +VERSION ?= $(shell cat VERSION)
10 +MODULE := internetpressure.io/probe-agent
11 +LDFLAGS := -s -w -X main.version=$(VERSION)
12 +GOFLAGS := -trimpath
13 +DIST := dist
14 +HOST_OS := $(shell go env GOOS)
15 +HOST_ARCH := $(shell go env GOARCH)
16 +T ?= www.cloudflare.com
17 +
18 +.PHONY: all build build-host test lint vet fmt run-dev once clean checksums
19 +
20 +all: lint test build
21 +
22 +build: $(DIST)/ip-probe-darwin-arm64 $(DIST)/ip-probe-linux-amd64 checksums
23 +
24 +$(DIST)/ip-probe-darwin-arm64: $(shell find . -name '*.go' -not -path './dist/*') go.mod go.sum VERSION
25 + @mkdir -p $(DIST)
26 + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $@ .
27 +
28 +$(DIST)/ip-probe-linux-amd64: $(shell find . -name '*.go' -not -path './dist/*') go.mod go.sum VERSION
29 + @mkdir -p $(DIST)
30 + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $@ .
31 +
32 +# Convenience: a binary for the machine running make (used by run-dev / once).
33 +build-host:
34 + @mkdir -p $(DIST)
35 + CGO_ENABLED=0 go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(DIST)/ip-probe .
36 +
37 +checksums:
38 + @cd $(DIST) && shasum -a 256 ip-probe-darwin-arm64 ip-probe-linux-amd64 > SHA256SUMS && cat SHA256SUMS
39 +
40 +test:
41 + go test ./...
42 +
43 +vet:
44 + go vet ./...
45 +
46 +fmt:
47 + @test -z "$$(gofmt -l . | grep -v '^dist/')" || (echo "gofmt needed on:" && gofmt -l . && exit 1)
48 +
49 +lint: fmt vet
50 +
51 +run-dev: build-host
52 + ./$(DIST)/ip-probe run --config probe.dev.yaml
53 +
54 +once: build-host
55 + ./$(DIST)/ip-probe once --target $(T)
56 +
57 +clean:
58 + rm -rf $(DIST)
added services/probe-agent/README.md +198 −0
@@ -0,0 +1,198 @@
1 +# ip-probe — InternetPressure.io probe agent
2 +
3 +Single static Go binary that measures HTTP / DNS / ICMP / traceroute against the targets assigned by the
4 +ingestion API and ships signed, gzip-compressed batches. Implements `docs/PROBE-PROTOCOL.md` (v1) exactly.
5 +
6 +```
7 +services/probe-agent/
8 +├── main.go, agent.go, once.go CLI + wiring (run / once / check-config / --version)
9 +├── internal/
10 +│ ├── protocol/ wire types (config, batch, measurement, traceroute, health, error codes)
11 +│ ├── signer/ HMAC-SHA256 request signature (+ test vectors, see below)
12 +│ ├── config/ probe.yaml (+ IP_PROBE_* env) — flat YAML subset parser, no third-party dependency
13 +│ ├── client/ signed HTTP client (GET /config, POST /batch, GET /agent/latest) + clock offset EWMA
14 +│ ├── sched/ priority timer wheel: intervals, jitter, spread, boost, concurrency + per-host limits
15 +│ ├── checks/http httptrace timings, fresh connection, no redirects, 16 KiB body cap, error mapping
16 +│ ├── checks/dns one A query per resolver (system resolver or miekg/dns UDP + TCP on truncation)
17 +│ ├── checks/ping unprivileged ICMP echo (udp4/udp6) with tcp-connect fallback; also the "tcp" check
18 +│ ├── checks/traceroute system traceroute binary + parser (macOS / Linux) + route_hash
19 +│ ├── batcher/ queue → JSON → gzip → signed POST; health ≤ 1/min; spool + backoff 5 s → 5 min
20 +│ ├── spool/ data_dir/spool/<unixnano>.json.gz, 200 MiB cap, oldest first
21 +│ ├── identity/ ipinfo.io → ip-api.com (public IP, ASN, org, country, city, 2-decimal lat/lon)
22 +│ ├── health/ counters, /healthz, /metrics (Prometheus text)
23 +│ └── update/ optional self-update (sha256-verified, atomic rename, exit 0)
24 +├── deploy/launchd/io.internetpressure.probe.plist · deploy/systemd/ip-probe.service · deploy/install.sh
25 +├── Makefile · VERSION · probe.example.yaml
26 +└── dist/ (make build) ip-probe-darwin-arm64, ip-probe-linux-amd64, SHA256SUMS
27 +```
28 +
29 +Dependencies: Go standard library, `github.com/miekg/dns`, `golang.org/x/net` (icmp, ipv4, ipv6). Nothing else.
30 +
31 +## Build & test
32 +
33 +```bash
34 +make build # CGO_ENABLED=0, -trimpath, -ldflags "-s -w -X main.version=$(cat VERSION)" → dist/
35 +make test # go test ./...
36 +make lint # gofmt + go vet
37 +make once T=www.cloudflare.com # build for this host and run every check once (no server needed)
38 +```
39 +
40 +## Commands
41 +
42 +| Command | Purpose |
43 +|---|---|
44 +| `ip-probe run [--config F]` | Run the agent (default command). |
45 +| `ip-probe once --target HOST [--url U] [--ip IP] [--port N] [--no-traceroute] [--no-ping]` | Run http + dns (all resolvers) + ping + traceroute once and print JSON. Works without a config file or server. |
46 +| `ip-probe check-config [--config F]` | Validate the configuration, print it with the key redacted. |
47 +| `ip-probe --version` | `ip-probe 0.1.0 (darwin/arm64, go1.26.3)` |
48 +
49 +Config lookup order: `--config`, `$IP_PROBE_CONFIG`, `/etc/internetpressure/probe.yaml`, `~/.internetpressure/probe.yaml`.
50 +
51 +## Configuration (`probe.yaml`)
52 +
53 +```yaml
54 +probe_id: ca-qc-01
55 +key: <64 hex chars> # HMAC key issued by the admin API
56 +ingest_url: https://www.internetpressure.io/ingest/v1
57 +data_dir: /var/lib/internetpressure # spool/ + config.json (last good config) + update state; 0700
58 +listen: 127.0.0.1:9381 # local /healthz + /metrics
59 +log_level: info # debug | info | warn | error
60 +allow_self_update: true
61 +resolvers_override: [] # optional: ["system", "google=8.8.8.8:53", "1.1.1.1"]
62 +max_concurrency: 8 # global cap on concurrent checks
63 +```
64 +
65 +Every key can be overridden by `IP_PROBE_<UPPER_KEY>` (lists comma-separated), e.g.
66 +`IP_PROBE_PROBE_ID=ca-qc-01 IP_PROBE_KEY=… ip-probe run`. The file is a flat YAML document (scalars, inline or
67 +block lists, comments) parsed without a YAML library — nested mappings are rejected.
68 +
69 +Logs go to stderr: human-readable text on a terminal, JSON lines otherwise (launchd / journald).
70 +
71 +## How signing works
72 +
73 +Every request carries `X-IP-Probe`, `X-IP-Timestamp` (Unix seconds) and `X-IP-Signature`:
74 +
75 +```
76 +canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + sha256_hex(body_bytes_as_sent)
77 +signature = lowercase_hex( HMAC-SHA256( hex_decode(key), canonical ) )
78 +```
79 +
80 +* `PATH` is the request path without query string (`/ingest/v1/batch`).
81 +* For `POST /batch` the body is gzip → the hash covers the **compressed bytes** exactly as sent. Spooled batches
82 + are the compressed bytes, so a retry re-signs with a fresh timestamp without touching the payload.
83 +* For GET the body hash is `sha256_hex("")` = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
84 +* The timestamp is the local clock corrected by the estimated offset (`clock_offset_ms`, see below), so a probe
85 + with a drifting clock still passes the ±300 s window. On `401 … skew …` the agent resyncs from the response
86 + `Date` header and retries the batch once.
87 +
88 +### Test vector (also in `internal/signer/signer_test.go`; computed independently with Python `hmac`)
89 +
90 +| | |
91 +|---|---|
92 +| key (hex) | `000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f` |
93 +| probe_id | `ca-qc-01` |
94 +| timestamp | `1789189804` (2026-09-12T05:10:04Z) |
95 +| **POST** path | `/ingest/v1/batch` |
96 +| body (raw, not gzipped for the vector) | `{"probe_id":"ca-qc-01","agent_version":"0.1.0","measurements":[]}` |
97 +| sha256(body) | `9fd6962cedcf4a7aedb13c38f7f63137fdbbbf3703b447ebd761234981847c65` |
98 +| canonical | `POST\n/ingest/v1/batch\n1789189804\n9fd6962c…847c65` |
99 +| **signature** | `111075ee8d4c20723598c8f75d5ad89433f0385ff965f123302d2b9411280723` |
100 +| **GET** path | `/ingest/v1/config` (body empty) |
101 +| canonical | `GET\n/ingest/v1/config\n1789189804\ne3b0c442…52b855` |
102 +| **signature** | `3798599e7f1bed2dab170d2aacf5f5288ec7cc591a64bd37285d3ea3c3450a8b` |
103 +
104 +Python reference:
105 +
106 +```python
107 +import hmac, hashlib
108 +key = bytes.fromhex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
109 +canonical = f"POST\n/ingest/v1/batch\n1789189804\n{hashlib.sha256(body).hexdigest()}".encode()
110 +hmac.new(key, canonical, hashlib.sha256).hexdigest() # == 111075ee…0723
111 +```
112 +
113 +## Behaviour summary
114 +
115 +* **Start-up**: load config → start `/healthz` → apply `data_dir/config.json` (last good config, offline start)
116 + → `GET /config` with backoff 5 s → 5 min until it succeeds (warn, never fatal) → refresh every
117 + `config_refresh_seconds` (min 30 s) or immediately when a batch response carries another `config_version`.
118 + Target adds/removes/edits and interval changes apply live.
119 +* **Scheduler**: single timer wheel. `http` every `tiers[tier]`, `dns` every `dns_every` (one query per resolver,
120 + sequential), `ping` every `ping_every`, `traceroute` every `traceroute_every`. ±10 % jitter, first run
121 + uniformly spread over one interval, `boost` factor honoured until `until`, floor of **10 s** (server values
122 + below are clamped and logged). Global limit `max_concurrency`, never two checks on the same hostname at once,
123 + never two traceroutes at once. Panics in a check are recovered and counted.
124 +* **http**: fresh connection (keep-alive disabled, no proxy, HTTP/2 attempted), `httptrace` gives `dns_ms`,
125 + `tcp_ms`, `tls_ms`; `ttfb_ms` = start → first byte, `total_ms` = start → body (≤ 16 KiB) read. Redirects are
126 + not followed (3xx is `ok`). `ok = connected ∧ TLS ok ∧ status < 500`; 4xx keeps `ok=true` with
127 + `error="http_4xx"`, 5xx → `ok=false, error="http_5xx"`. Pinned `ip` is dialed while the hostname stays in SNI
128 + and `Host`. Error mapping: `dns_fail`, `tcp_timeout`, `tcp_refused`, `tcp_reset`, `tls_fail`, `tls_cert`,
129 + `http_timeout`, `reset`, `other`.
130 +* **dns**: `system` → OS resolver; others → UDP A query, EDNS0 1232, 3 s, retried once over TCP only when
131 + truncated. `ok = NOERROR ∧ ≥1 answer`; answers sorted.
132 +* **ping**: 5 echoes, 200 ms apart, 2 s each, through an unprivileged ICMP datagram socket. If the socket cannot
133 + be opened the measurement becomes kind `tcp` (5 sequential connects to `port`) with `error="icmp_unavailable"`
134 + when successful, `unreachable` when nothing answered. `jitter_ms` = mean absolute successive difference.
135 +* **traceroute**: `traceroute -n -q 1 -w 2 -m 30 <ip>` (60 s cap), hops with `*`, `route_hash` = sha1 of
136 + `ip1|ip2|*|…`, `reached` when the last hop is the destination, `total_ms` = RTT of the last answered hop.
137 +* **Batching**: flush every `batch_flush_seconds` or at `max_batch`; health block at most once per minute.
138 + Network error / 5xx / 429 → gzip batch spooled to `data_dir/spool/`, backoff 5 s → 5 min, oldest first when
139 + the server is back. 200 MiB spool cap drops the oldest files. Other 4xx → dropped and logged.
140 +* **Shutdown** (SIGTERM/SIGINT): stop scheduling, wait for in-flight checks, one final POST with a 5 s timeout,
141 + spool whatever remains.
142 +* **Identity**: ipinfo.io (fallback ip-api.com) at start and hourly; only public IP, ASN, org, country, city and
143 + coordinates rounded to 2 decimals are kept.
144 +* **Self-update**: every 6 h `GET /agent/latest`; a different version with an asset for `GOOS-GOARCH` is
145 + downloaded to `data_dir/update.tmp`, sha256-verified, `chmod 0755`, renamed over the running binary, then the
146 + agent exits 0 and the supervisor restarts it. Each version is attempted at most once (`data_dir/update.last`).
147 +
148 +## Local endpoints
149 +
150 +* `GET http://127.0.0.1:9381/healthz` →
151 + `{"ok":true,"probe_id":"…","version":"0.1.0","buffered":0,"spool_bytes":0,"last_flush":"…","last_config":"…","config_version":"…","targets":250,"clock_offset_ms":-14,"uptime_s":…,"capabilities":[…]}`
152 +* `GET /metrics` → `ip_probe_measurements_total{kind}`, `ip_probe_errors_total{code}`, `ip_probe_buffered`,
153 + `ip_probe_spool_bytes`, `ip_probe_flush_failures_total`, `ip_probe_check_panics_total`,
154 + `ip_probe_clock_offset_ms`, `ip_probe_targets`, `ip_probe_uptime_seconds`, `ip_probe_rss_mb`, `ip_probe_goroutines`, `ip_probe_info`.
155 +
156 +## Install
157 +
158 +```bash
159 +make build
160 +sudo deploy/install.sh ca-qc-01 <64-hex-key> https://www.internetpressure.io/ingest/v1
161 +```
162 +
163 +The installer is idempotent: it installs `dist/ip-probe-<os>-<arch>` to `/usr/local/bin/ip-probe`, writes
164 +`/etc/internetpressure/probe.yaml` (0600; kept if id/key/url are unchanged), creates `/var/lib/internetpressure`
165 +(0700) and (re)starts the service. Re-running upgrades the binary and restarts.
166 +
167 +* **macOS**: LaunchDaemon `/Library/LaunchDaemons/io.internetpressure.probe.plist` (`KeepAlive`, `RunAtLoad`,
168 + `UserName` = `$IP_PROBE_USER` or the invoking user, logs in `/var/log/internetpressure-probe.log`).
169 + Unprivileged ICMP and UDP traceroute work for any user.
170 +* **Linux**: system user `ip-probe`, `/etc/systemd/system/ip-probe.service` (`Restart=always`, `RestartSec=5`),
171 + `/etc/sysctl.d/60-ip-probe.conf` sets `net.ipv4.ping_group_range = 0 2147483647` so ICMP needs no
172 + capability. `traceroute` must be installed (`apt install traceroute`).
173 +
174 +## Troubleshooting
175 +
176 +| Symptom | Cause / fix |
177 +|---|---|
178 +| `config fetch failed … http 401` | Wrong `key`/`probe_id`, or clock skew > 300 s (fix NTP). The agent keeps retrying and runs from the cached config meanwhile. |
179 +| `ping` measurements have `kind: "tcp"` and `error: "icmp_unavailable"` | Linux without `net.ipv4.ping_group_range` (installer sets it; otherwise `sysctl -w net.ipv4.ping_group_range="0 2147483647"`). |
180 +| No traceroutes | No `traceroute` binary at `/usr/sbin/traceroute` (macOS) / `/usr/bin/traceroute` (Linux). Capability list in `/healthz` omits `traceroute`. |
181 +| `interval below minimum, clamped` | Server asked for < 10 s; the agent enforces the 10 s floor (ethics rule). |
182 +| `batch spooled … server unavailable` | Normal during an outage: `ls /var/lib/internetpressure/spool`; drained oldest-first when the server returns. |
183 +| `local clock differs from server by more than 120 s` | Enable NTP; signatures still pass because timestamps are offset-corrected, but measurements are timestamped with the local clock. |
184 +| Debug a target | `ip-probe once --target host.example --url https://host.example/path` |
185 +| Where do logs go? | macOS `/var/log/internetpressure-probe.log`; Linux `journalctl -u ip-probe -f`. JSON lines, `log_level: debug` for per-batch details. |
186 +
187 +## Notes on the protocol implementation
188 +
189 +* Every wire field name/type follows `docs/PROBE-PROTOCOL.md`. Fields not relevant to a `kind` are omitted
190 + (pointer + `omitempty`), which the protocol allows ("omitted or null"); `total_ms`/`rtt_ms` in traceroutes are
191 + explicit `null` when unknown.
192 +* `ttfb_ms` is measured from the start of the check (before DNS), i.e. the conventional time-to-first-byte, so
193 + `dns_ms + tcp_ms + tls_ms ≤ ttfb_ms ≤ total_ms` as in the protocol's example values.
194 +* `http_4xx` is reported with `ok=true` (connection + TLS fine, status < 500), per the `ok` definition.
195 +* On the ICMP → TCP fallback the measurement is `kind:"tcp"` and, when at least one connect succeeded,
196 + `error:"icmp_unavailable"` with `ok:true` — an informational code so the server can tell a real `tcp` check
197 + from a degraded `ping`.
198 +* `rss_mb` is computed from `runtime.MemStats` (`Sys − HeapReleased`), a close upper bound of the true RSS.
added services/probe-agent/VERSION +1 −0
@@ -0,0 +1 @@
1 +0.1.0
added services/probe-agent/agent.go +381 −0
@@ -0,0 +1,381 @@
1 +package main
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "errors"
7 + "fmt"
8 + "log/slog"
9 + "os"
10 + "path/filepath"
11 + "sync"
12 + "sync/atomic"
13 + "time"
14 +
15 + "internetpressure.io/probe-agent/internal/batcher"
16 + checkdns "internetpressure.io/probe-agent/internal/checks/dns"
17 + checkhttp "internetpressure.io/probe-agent/internal/checks/http"
18 + checkping "internetpressure.io/probe-agent/internal/checks/ping"
19 + checktrace "internetpressure.io/probe-agent/internal/checks/traceroute"
20 + "internetpressure.io/probe-agent/internal/client"
21 + "internetpressure.io/probe-agent/internal/config"
22 + "internetpressure.io/probe-agent/internal/health"
23 + "internetpressure.io/probe-agent/internal/identity"
24 + "internetpressure.io/probe-agent/internal/protocol"
25 + "internetpressure.io/probe-agent/internal/sched"
26 + "internetpressure.io/probe-agent/internal/signer"
27 + "internetpressure.io/probe-agent/internal/spool"
28 + "internetpressure.io/probe-agent/internal/update"
29 +)
30 +
31 +const (
32 + cachedConfigFile = "config.json"
33 + configBackoffMin = 5 * time.Second
34 + configBackoffMax = 5 * time.Minute
35 + defaultRefresh = 300 * time.Second
36 + clockWarnThreshold = 120 * time.Second
37 + clockWarnEvery = 10 * time.Minute
38 +)
39 +
40 +// agent wires every component together.
41 +type agent struct {
42 + cfg *config.Config
43 + log *slog.Logger
44 +
45 + client *client.Client
46 + spool *spool.Spool
47 + health *health.State
48 + batcher *batcher.Batcher
49 + sched *sched.Scheduler
50 + identity *identity.Service
51 + updater *update.Updater
52 +
53 + httpCheck *checkhttp.Checker
54 + dnsCheck *checkdns.Checker
55 + pingCheck *checkping.Checker
56 + traceCheck *checktrace.Checker
57 + caps []string
58 +
59 + remote atomic.Pointer[protocol.RemoteConfig]
60 + refreshCh chan struct{}
61 + lastClock time.Time
62 + clockMu sync.Mutex
63 + updated atomic.Bool
64 + cancel context.CancelFunc
65 +}
66 +
67 +func newAgent(cfg *config.Config, log *slog.Logger) (*agent, error) {
68 + if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
69 + return nil, fmt.Errorf("data_dir: %w", err)
70 + }
71 + sg, err := signer.New(cfg.ProbeID, cfg.Key)
72 + if err != nil {
73 + return nil, err
74 + }
75 + cl, err := client.New(cfg.IngestURL, sg, version)
76 + if err != nil {
77 + return nil, err
78 + }
79 + sp, err := spool.Open(filepath.Join(cfg.DataDir, "spool"), spool.DefaultCap)
80 + if err != nil {
81 + return nil, err
82 + }
83 +
84 + a := &agent{cfg: cfg, log: log, client: cl, spool: sp, refreshCh: make(chan struct{}, 1)}
85 + ua := client.UserAgent(version)
86 + a.httpCheck = &checkhttp.Checker{UserAgent: ua}
87 + a.dnsCheck = &checkdns.Checker{}
88 + a.pingCheck = &checkping.Checker{ICMP: checkping.Detect()}
89 + a.traceCheck = checktrace.Detect()
90 + a.caps = []string{"http", "dns"}
91 + if a.pingCheck.ICMP {
92 + a.caps = append(a.caps, "ping")
93 + } else {
94 + a.caps = append(a.caps, "tcp")
95 + log.Warn("unprivileged ICMP unavailable: ping checks fall back to tcp connects", "hint", checkping.LinuxHint())
96 + }
97 + if a.traceCheck.Available() {
98 + a.caps = append(a.caps, "traceroute")
99 + } else {
100 + log.Warn("traceroute binary not found: traceroute disabled")
101 + }
102 +
103 + a.identity = identity.New(ua)
104 + a.sched = sched.New(a.runJob, sched.Options{
105 + MaxConcurrency: cfg.MaxConcurrency,
106 + Logger: log,
107 + CanPing: a.pingCheck.ICMP,
108 + CanTraceroute: a.traceCheck.Available(),
109 + })
110 + a.health = health.New(cfg.ProbeID, version, a.caps, health.Gauges{
111 + Buffered: func() int { return a.batcher.Buffered() },
112 + SpoolBytes: sp.Bytes,
113 + ClockOffsetMs: cl.Clock.OffsetMs,
114 + Targets: a.sched.Targets,
115 + Identity: a.identity.Current,
116 + })
117 + a.batcher = batcher.New(cfg.ProbeID, version, cl, sp, batcher.Hooks{
118 + Health: a.health.Snapshot,
119 + OnResponse: a.onBatchResponse,
120 + OnFlushFailure: a.health.RecordFlushFailure,
121 + OnDelivered: func(t time.Time) {
122 + a.health.SetLastFlush(t)
123 + a.checkClock()
124 + },
125 + }, log)
126 + a.updater = &update.Updater{Fetcher: cl, Version: version, DataDir: cfg.DataDir, Log: log}
127 + return a, nil
128 +}
129 +
130 +// Run starts every loop and blocks until ctx is cancelled (SIGINT/SIGTERM) or a self-update requires a restart.
131 +func (a *agent) Run(parent context.Context) error {
132 + ctx, cancel := context.WithCancel(parent)
133 + a.cancel = cancel
134 + defer cancel()
135 + a.log.Info("ip-probe starting", "version", version, "probe_id", a.cfg.ProbeID, "ingest_url", a.cfg.IngestURL,
136 + "data_dir", a.cfg.DataDir, "listen", a.cfg.Listen, "capabilities", a.caps, "max_concurrency", a.cfg.MaxConcurrency)
137 +
138 + // Offline start: reuse the last good config so checks begin before the server answers.
139 + if rc, err := a.loadCachedConfig(); err == nil {
140 + a.log.Info("starting with cached config", "config_version", rc.ConfigVersion, "targets", len(rc.Targets))
141 + a.applyRemote(rc, false)
142 + }
143 +
144 + var wg sync.WaitGroup
145 + start := func(name string, f func(context.Context)) {
146 + wg.Add(1)
147 + go func() {
148 + defer wg.Done()
149 + f(ctx)
150 + a.log.Debug("loop stopped", "loop", name)
151 + }()
152 + }
153 + go func() {
154 + if err := a.health.Serve(ctx, a.cfg.Listen); err != nil {
155 + a.log.Error("local health endpoint failed", "listen", a.cfg.Listen, "err", err)
156 + }
157 + }()
158 + var schedDone sync.WaitGroup
159 + schedDone.Add(1)
160 + go func() { defer schedDone.Done(); a.sched.Run(ctx) }()
161 + start("batcher", a.batcher.Run)
162 + start("config", a.configLoop)
163 + start("identity", func(ctx context.Context) {
164 + a.identity.Run(ctx, func(id *protocol.Identity, err error) {
165 + if err != nil {
166 + a.log.Warn("identity lookup failed", "err", err)
167 + return
168 + }
169 + a.log.Info("identity", "public_ip", id.PublicIP, "asn", id.ASN, "org", id.Org, "country", id.Country,
170 + "city", id.City, "source", id.Source)
171 + })
172 + })
173 + if a.cfg.AllowSelfUpdate {
174 + start("update", func(ctx context.Context) {
175 + a.updater.Run(ctx, func() {
176 + a.updated.Store(true)
177 + a.log.Info("self-update installed; shutting down for the supervisor to restart")
178 + cancel()
179 + })
180 + })
181 + } else {
182 + a.log.Info("self-update disabled by configuration")
183 + }
184 +
185 + <-ctx.Done()
186 + a.log.Info("shutting down", "reason", reason(parent, a.updated.Load()))
187 + // 1. stop scheduling and wait for in-flight checks; 2. final flush (one 5 s attempt, then spool).
188 + schedDone.Wait()
189 + wg.Wait()
190 + a.batcher.Stop()
191 + a.log.Info("stopped", "buffered", a.batcher.Buffered(), "spool_bytes", a.spool.Bytes())
192 + return nil
193 +}
194 +
195 +func reason(parent context.Context, updated bool) string {
196 + switch {
197 + case updated:
198 + return "self-update"
199 + case parent.Err() != nil:
200 + return "signal"
201 + default:
202 + return "internal"
203 + }
204 +}
205 +
206 +// runJob executes one scheduled (target, family) job. Measurements from cancelled checks are dropped.
207 +func (a *agent) runJob(ctx context.Context, job sched.Job) {
208 + switch job.Family {
209 + case sched.FamilyHTTP:
210 + a.emit(ctx, a.httpCheck.Run(ctx, job.Target))
211 + case sched.FamilyDNS:
212 + a.emit(ctx, a.dnsCheck.Run(ctx, job.Target, a.resolvers())...)
213 + case sched.FamilyPing:
214 + a.emit(ctx, a.pingCheck.Run(ctx, job.Target))
215 + case sched.FamilyTCP:
216 + a.emit(ctx, a.pingCheck.RunTCP(ctx, job.Target))
217 + case sched.FamilyTraceroute:
218 + tr, err := a.traceCheck.Run(ctx, job.Target)
219 + if ctx.Err() != nil {
220 + return
221 + }
222 + if err != nil {
223 + a.log.Debug("traceroute failed", "target", job.Target.TargetID, "err", err)
224 + return
225 + }
226 + a.batcher.AddTraceroute(tr)
227 + }
228 +}
229 +
230 +func (a *agent) emit(ctx context.Context, ms ...protocol.Measurement) {
231 + if ctx.Err() != nil {
232 + return
233 + }
234 + for _, m := range ms {
235 + a.health.Record(m)
236 + }
237 + a.batcher.Add(ms...)
238 +}
239 +
240 +// resolvers returns the DNS resolver list: local override, else server config, else defaults.
241 +func (a *agent) resolvers() []protocol.Resolver {
242 + if len(a.cfg.ResolversOverride) > 0 {
243 + out := make([]protocol.Resolver, 0, len(a.cfg.ResolversOverride))
244 + for _, r := range a.cfg.ResolversOverride {
245 + id, addr, err := config.ParseResolver(r)
246 + if err == nil {
247 + out = append(out, protocol.Resolver{ID: id, Address: addr})
248 + }
249 + }
250 + return out
251 + }
252 + if rc := a.remote.Load(); rc != nil && len(rc.Resolvers) > 0 {
253 + return rc.Resolvers
254 + }
255 + return checkdns.DefaultResolvers
256 +}
257 +
258 +// configLoop fetches /config with backoff until it succeeds, then every config_refresh_seconds or on demand.
259 +func (a *agent) configLoop(ctx context.Context) {
260 + backoff := configBackoffMin
261 + for {
262 + rc, err := a.client.GetConfig(ctx)
263 + if ctx.Err() != nil {
264 + return
265 + }
266 + var wait time.Duration
267 + if err != nil {
268 + a.log.Warn("config fetch failed", "err", err, "retry_in", backoff)
269 + wait = backoff
270 + backoff *= 2
271 + if backoff > configBackoffMax {
272 + backoff = configBackoffMax
273 + }
274 + } else {
275 + backoff = configBackoffMin
276 + a.applyRemote(rc, true)
277 + wait = defaultRefresh
278 + if rc.Schedule.ConfigRefreshSeconds > 0 {
279 + wait = time.Duration(rc.Schedule.ConfigRefreshSeconds) * time.Second
280 + }
281 + if wait < 30*time.Second {
282 + wait = 30 * time.Second
283 + }
284 + }
285 + select {
286 + case <-ctx.Done():
287 + return
288 + case <-time.After(wait):
289 + case <-a.refreshCh:
290 + }
291 + }
292 +}
293 +
294 +// applyRemote installs a remote config everywhere and (optionally) persists it for offline starts.
295 +func (a *agent) applyRemote(rc *protocol.RemoteConfig, persist bool) {
296 + if rc.Probe.ProbeID != "" && rc.Probe.ProbeID != a.cfg.ProbeID {
297 + a.log.Warn("server config is for another probe id", "got", rc.Probe.ProbeID, "want", a.cfg.ProbeID)
298 + }
299 + prev := a.remote.Load()
300 + a.remote.Store(rc)
301 + a.sched.Apply(rc)
302 + a.sched.SetBoost(rc.Schedule.Boost)
303 + a.batcher.Configure(rc.Schedule.BatchFlushSeconds, rc.Schedule.MaxBatch)
304 + a.health.SetLastConfig(time.Now(), rc.ConfigVersion)
305 + if !rc.Probe.Enabled {
306 + a.log.Warn("probe disabled by server: idling (health only)")
307 + }
308 + if prev == nil || prev.ConfigVersion != rc.ConfigVersion {
309 + a.log.Info("config applied", "config_version", rc.ConfigVersion, "targets", len(rc.Targets),
310 + "resolvers", len(rc.Resolvers), "enabled", rc.Probe.Enabled, "tiers", rc.Schedule.Tiers,
311 + "dns_every", rc.Schedule.DNSEvery, "ping_every", rc.Schedule.PingEvery, "traceroute_every", rc.Schedule.TracerouteEvery)
312 + }
313 + if persist {
314 + if err := a.saveCachedConfig(rc); err != nil {
315 + a.log.Warn("could not persist config", "err", err)
316 + }
317 + }
318 + a.checkClock()
319 +}
320 +
321 +// onBatchResponse reacts to a batch reply: new config_version → refresh now; boost → apply.
322 +func (a *agent) onBatchResponse(resp *protocol.BatchResponse) {
323 + if resp.Boost != nil {
324 + a.sched.SetBoost(resp.Boost)
325 + }
326 + cur := a.remote.Load()
327 + if resp.ConfigVersion != "" && (cur == nil || cur.ConfigVersion != resp.ConfigVersion) {
328 + a.log.Info("server announced a new config version", "config_version", resp.ConfigVersion)
329 + select {
330 + case a.refreshCh <- struct{}{}:
331 + default:
332 + }
333 + }
334 +}
335 +
336 +// checkClock warns (rate-limited) when the estimated clock offset exceeds 120 s.
337 +func (a *agent) checkClock() {
338 + off := a.client.Clock.Offset()
339 + if off < 0 {
340 + off = -off
341 + }
342 + if off <= clockWarnThreshold {
343 + return
344 + }
345 + a.clockMu.Lock()
346 + defer a.clockMu.Unlock()
347 + if time.Since(a.lastClock) < clockWarnEvery {
348 + return
349 + }
350 + a.lastClock = time.Now()
351 + a.log.Warn("local clock differs from server by more than 120 s; fix NTP", "clock_offset_ms", a.client.Clock.OffsetMs())
352 +}
353 +
354 +func (a *agent) cachedConfigPath() string { return filepath.Join(a.cfg.DataDir, cachedConfigFile) }
355 +
356 +func (a *agent) loadCachedConfig() (*protocol.RemoteConfig, error) {
357 + data, err := os.ReadFile(a.cachedConfigPath())
358 + if err != nil {
359 + return nil, err
360 + }
361 + var rc protocol.RemoteConfig
362 + if err := json.Unmarshal(data, &rc); err != nil {
363 + return nil, err
364 + }
365 + if rc.ConfigVersion == "" {
366 + return nil, errors.New("cached config has no version")
367 + }
368 + return &rc, nil
369 +}
370 +
371 +func (a *agent) saveCachedConfig(rc *protocol.RemoteConfig) error {
372 + data, err := json.Marshal(rc)
373 + if err != nil {
374 + return err
375 + }
376 + tmp := a.cachedConfigPath() + ".tmp"
377 + if err := os.WriteFile(tmp, data, 0o600); err != nil {
378 + return err
379 + }
380 + return os.Rename(tmp, a.cachedConfigPath())
381 +}
added services/probe-agent/deploy/install.sh +139 −0
@@ -0,0 +1,139 @@
1 +#!/usr/bin/env bash
2 +# InternetPressure.io probe agent installer (idempotent).
3 +#
4 +# sudo deploy/install.sh <probe_id> <key> [ingest_url]
5 +#
6 +# - picks dist/ip-probe-<os>-<arch> (or $IP_PROBE_BINARY) and installs it to /usr/local/bin/ip-probe
7 +# - writes /etc/internetpressure/probe.yaml (0600) — existing file is kept unless probe_id/key/url differ
8 +# - creates /var/lib/internetpressure (0700, owned by the service user)
9 +# - macOS: installs the LaunchDaemon (UserName = $IP_PROBE_USER, default: the invoking user) and bootstraps it
10 +# - Linux: creates the ip-probe system user, sets net.ipv4.ping_group_range, installs + enables the unit
11 +# Re-running upgrades the binary and restarts the service.
12 +set -euo pipefail
13 +
14 +PROBE_ID="${1:-}"
15 +KEY="${2:-}"
16 +INGEST_URL="${3:-https://www.internetpressure.io/ingest/v1}"
17 +
18 +if [[ -z "$PROBE_ID" || -z "$KEY" ]]; then
19 + echo "usage: sudo $0 <probe_id> <key> [ingest_url]" >&2
20 + exit 2
21 +fi
22 +if [[ ! "$PROBE_ID" =~ ^[a-z0-9][a-z0-9-]{1,63}$ ]]; then
23 + echo "probe_id must be lowercase letters/digits/dashes" >&2; exit 2
24 +fi
25 +if [[ ! "$KEY" =~ ^[0-9a-fA-F]{64}$ ]]; then
26 + echo "key must be 64 hex characters" >&2; exit 2
27 +fi
28 +if [[ "$(id -u)" -ne 0 ]]; then
29 + echo "run as root (sudo)" >&2; exit 1
30 +fi
31 +
32 +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
33 +OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
34 +ARCH="$(uname -m)"
35 +case "$ARCH" in
36 + arm64|aarch64) ARCH=arm64 ;;
37 + x86_64|amd64) ARCH=amd64 ;;
38 +esac
39 +BIN_SRC="${IP_PROBE_BINARY:-$HERE/dist/ip-probe-$OS-$ARCH}"
40 +if [[ ! -x "$BIN_SRC" ]]; then
41 + echo "binary not found: $BIN_SRC (run 'make build' first or set IP_PROBE_BINARY)" >&2; exit 1
42 +fi
43 +
44 +BIN_DST=/usr/local/bin/ip-probe
45 +CONF_DIR=/etc/internetpressure
46 +CONF="$CONF_DIR/probe.yaml"
47 +DATA_DIR=/var/lib/internetpressure
48 +LOG=/var/log/internetpressure-probe.log
49 +
50 +if [[ "$OS" == "darwin" ]]; then
51 + SVC_USER="${IP_PROBE_USER:-${SUDO_USER:-root}}"
52 + SVC_GROUP="$(id -gn "$SVC_USER")"
53 +else
54 + SVC_USER="${IP_PROBE_USER:-ip-probe}"
55 + if ! id "$SVC_USER" >/dev/null 2>&1; then
56 + useradd --system --home-dir "$DATA_DIR" --shell /usr/sbin/nologin --user-group "$SVC_USER" 2>/dev/null \
57 + || adduser --system --home "$DATA_DIR" --no-create-home --group "$SVC_USER"
58 + echo "created system user $SVC_USER"
59 + fi
60 + SVC_GROUP="$(id -gn "$SVC_USER")"
61 +fi
62 +
63 +# --- binary ---------------------------------------------------------------------------------------------------
64 +NEW_VERSION="$("$BIN_SRC" --version | awk '{print $2}')"
65 +OLD_VERSION="$([[ -x "$BIN_DST" ]] && "$BIN_DST" --version 2>/dev/null | awk '{print $2}' || echo none)"
66 +install -d -m 0755 /usr/local/bin
67 +install -m 0755 "$BIN_SRC" "$BIN_DST.new"
68 +mv -f "$BIN_DST.new" "$BIN_DST"
69 +echo "binary: $OLD_VERSION → $NEW_VERSION at $BIN_DST"
70 +
71 +# --- config ---------------------------------------------------------------------------------------------------
72 +install -d -m 0755 "$CONF_DIR"
73 +TMP_CONF="$(mktemp)"
74 +cat > "$TMP_CONF" <<EOF
75 +# Written by deploy/install.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ). Edit freely; re-running the installer with the
76 +# same probe_id/key/url keeps your changes.
77 +probe_id: $PROBE_ID
78 +key: $(echo "$KEY" | tr '[:upper:]' '[:lower:]')
79 +ingest_url: $INGEST_URL
80 +data_dir: $DATA_DIR
81 +listen: 127.0.0.1:9381
82 +log_level: info
83 +allow_self_update: true
84 +resolvers_override: []
85 +max_concurrency: 8
86 +EOF
87 +if [[ -f "$CONF" ]] && grep -q "^probe_id: $PROBE_ID\$" "$CONF" && grep -qi "^key: $KEY\$" "$CONF" \
88 + && grep -q "^ingest_url: $INGEST_URL\$" "$CONF"; then
89 + echo "config: unchanged ($CONF)"
90 + rm -f "$TMP_CONF"
91 +else
92 + install -m 0600 -o "$SVC_USER" -g "$SVC_GROUP" "$TMP_CONF" "$CONF"
93 + rm -f "$TMP_CONF"
94 + echo "config: written $CONF"
95 +fi
96 +"$BIN_DST" check-config --config "$CONF" >/dev/null
97 +
98 +# --- data dir + log --------------------------------------------------------------------------------------------
99 +install -d -m 0700 -o "$SVC_USER" -g "$SVC_GROUP" "$DATA_DIR"
100 +touch "$LOG"; chown "$SVC_USER:$SVC_GROUP" "$LOG"; chmod 0640 "$LOG"
101 +
102 +# --- service --------------------------------------------------------------------------------------------------
103 +if [[ "$OS" == "darwin" ]]; then
104 + PLIST=/Library/LaunchDaemons/io.internetpressure.probe.plist
105 + sed "s/__USER__/$SVC_USER/" "$HERE/deploy/launchd/io.internetpressure.probe.plist" > "$PLIST.new"
106 + chown root:wheel "$PLIST.new"; chmod 0644 "$PLIST.new"
107 + if launchctl print system/io.internetpressure.probe >/dev/null 2>&1; then
108 + launchctl bootout system/io.internetpressure.probe || true
109 + sleep 1
110 + fi
111 + mv -f "$PLIST.new" "$PLIST"
112 + launchctl bootstrap system "$PLIST"
113 + launchctl kickstart -k system/io.internetpressure.probe
114 + echo "service: launchd io.internetpressure.probe running as $SVC_USER"
115 + echo "logs: tail -f $LOG"
116 +else
117 + if [[ ! -x /usr/bin/traceroute && ! -x /usr/sbin/traceroute ]]; then
118 + echo "warning: traceroute not installed (apt install traceroute) — traceroute checks disabled" >&2
119 + fi
120 + install -d -m 0755 /etc/sysctl.d
121 + echo 'net.ipv4.ping_group_range = 0 2147483647' > /etc/sysctl.d/60-ip-probe.conf
122 + sysctl -q -p /etc/sysctl.d/60-ip-probe.conf || sysctl -q -w net.ipv4.ping_group_range="0 2147483647" || true
123 + install -m 0644 "$HERE/deploy/systemd/ip-probe.service" /etc/systemd/system/ip-probe.service
124 + if [[ "$SVC_USER" != "ip-probe" ]]; then
125 + sed -i "s/^User=.*/User=$SVC_USER/; s/^Group=.*/Group=$SVC_GROUP/" /etc/systemd/system/ip-probe.service
126 + fi
127 + systemctl daemon-reload
128 + systemctl enable --now ip-probe.service
129 + systemctl restart ip-probe.service
130 + echo "service: systemd ip-probe.service running as $SVC_USER"
131 + echo "logs: journalctl -u ip-probe -f"
132 +fi
133 +
134 +sleep 2
135 +if curl -fsS http://127.0.0.1:9381/healthz >/dev/null 2>&1; then
136 + echo "health: $(curl -fsS http://127.0.0.1:9381/healthz)"
137 +else
138 + echo "health: endpoint not answering yet (check the logs)"
139 +fi
added services/probe-agent/deploy/launchd/io.internetpressure.probe.plist +59 −0
@@ -0,0 +1,59 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 +<!--
4 + InternetPressure.io probe agent — LaunchDaemon (macOS).
5 + Installed by deploy/install.sh to /Library/LaunchDaemons/io.internetpressure.probe.plist.
6 + __USER__ is replaced by the installer with the account that runs the probe (unprivileged ICMP works for any
7 + user on macOS; traceroute uses UDP probes and needs no root).
8 + Load: sudo launchctl bootstrap system /Library/LaunchDaemons/io.internetpressure.probe.plist
9 + Unload: sudo launchctl bootout system/io.internetpressure.probe
10 +-->
11 +<plist version="1.0">
12 +<dict>
13 + <key>Label</key>
14 + <string>io.internetpressure.probe</string>
15 +
16 + <key>ProgramArguments</key>
17 + <array>
18 + <string>/usr/local/bin/ip-probe</string>
19 + <string>run</string>
20 + <string>--config</string>
21 + <string>/etc/internetpressure/probe.yaml</string>
22 + </array>
23 +
24 + <key>UserName</key>
25 + <string>__USER__</string>
26 +
27 + <key>WorkingDirectory</key>
28 + <string>/var/lib/internetpressure</string>
29 +
30 + <key>RunAtLoad</key>
31 + <true/>
32 +
33 + <key>KeepAlive</key>
34 + <true/>
35 +
36 + <!-- Wait 5 s between restarts (also after a self-update exit 0). -->
37 + <key>ThrottleInterval</key>
38 + <integer>5</integer>
39 +
40 + <key>ExitTimeOut</key>
41 + <integer>15</integer>
42 +
43 + <key>StandardOutPath</key>
44 + <string>/var/log/internetpressure-probe.log</string>
45 + <key>StandardErrorPath</key>
46 + <string>/var/log/internetpressure-probe.log</string>
47 +
48 + <key>EnvironmentVariables</key>
49 + <dict>
50 + <key>PATH</key>
51 + <string>/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin</string>
52 + </dict>
53 +
54 + <key>ProcessType</key>
55 + <string>Background</string>
56 + <key>LowPriorityIO</key>
57 + <true/>
58 +</dict>
59 +</plist>
added services/probe-agent/deploy/systemd/ip-probe.service +53 −0
@@ -0,0 +1,53 @@
1 +# InternetPressure.io probe agent — systemd unit (Linux).
2 +# Installed by deploy/install.sh to /etc/systemd/system/ip-probe.service.
3 +#
4 +# ICMP without root: the agent opens an unprivileged ICMP datagram socket, which Linux only allows for groups
5 +# inside net.ipv4.ping_group_range. The installer writes /etc/sysctl.d/60-ip-probe.conf with
6 +# net.ipv4.ping_group_range = 0 2147483647
7 +# so no capability is required. Without it the agent falls back to TCP-connect "ping" (kind "tcp").
8 +# Alternative (not recommended): uncomment AmbientCapabilities below to grant CAP_NET_RAW instead.
9 +#
10 +# traceroute: the system `traceroute` binary (UDP probes) must be installed (apt install traceroute /
11 +# dnf install traceroute). Modern traceroute needs no root for UDP probes when ping_group_range is set;
12 +# otherwise the binary is usually setuid or has file capabilities set by the distribution.
13 +
14 +[Unit]
15 +Description=InternetPressure.io probe agent
16 +Documentation=https://www.internetpressure.io/probes
17 +After=network-online.target
18 +Wants=network-online.target
19 +
20 +[Service]
21 +Type=simple
22 +User=ip-probe
23 +Group=ip-probe
24 +DynamicUser=no
25 +ExecStart=/usr/local/bin/ip-probe run --config /etc/internetpressure/probe.yaml
26 +WorkingDirectory=/var/lib/internetpressure
27 +Restart=always
28 +RestartSec=5
29 +TimeoutStopSec=15
30 +KillSignal=SIGTERM
31 +
32 +# Uncomment to use raw sockets instead of net.ipv4.ping_group_range (not needed when the sysctl is set):
33 +#AmbientCapabilities=CAP_NET_RAW
34 +#CapabilityBoundingSet=CAP_NET_RAW
35 +
36 +# Hardening
37 +NoNewPrivileges=true
38 +ProtectSystem=strict
39 +ProtectHome=true
40 +PrivateTmp=true
41 +ReadWritePaths=/var/lib/internetpressure /usr/local/bin/ip-probe
42 +ProtectKernelTunables=true
43 +ProtectControlGroups=true
44 +RestrictSUIDSGID=true
45 +LockPersonality=true
46 +MemoryDenyWriteExecute=true
47 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
48 +SystemCallArchitectures=native
49 +LimitNOFILE=4096
50 +MemoryMax=256M
51 +
52 +[Install]
53 +WantedBy=multi-user.target
added services/probe-agent/go.mod +10 −0
@@ -0,0 +1,10 @@
1 +module internetpressure.io/probe-agent
2 +
3 +go 1.26.3
4 +
5 +require (
6 + github.com/miekg/dns v1.1.73
7 + golang.org/x/net v0.59.0
8 +)
9 +
10 +require golang.org/x/sys v0.48.0 // indirect
added services/probe-agent/go.sum +8 −0
@@ -0,0 +1,8 @@
1 +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
2 +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
3 +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
4 +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
5 +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
6 +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
7 +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
8 +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
added services/probe-agent/internal/batcher/batcher.go +372 −0
@@ -0,0 +1,372 @@
1 +// Package batcher buffers measurements and traceroutes, and delivers them as gzip-compressed signed batches.
2 +//
3 +// Delivery rules (docs/PROBE-PROTOCOL.md § POST /batch): flush every batch_flush_seconds or when max_batch is
4 +// reached; health block at most once per minute; network error / 5xx → spool to disk and back off 5 s → 5 min,
5 +// drain oldest first when the server is back (re-signed with a fresh timestamp, bytes unchanged); 4xx → drop
6 +// (except "401 skew": resync clock and retry once).
7 +package batcher
8 +
9 +import (
10 + "bytes"
11 + "compress/gzip"
12 + "context"
13 + "encoding/json"
14 + "errors"
15 + "log/slog"
16 + "sync"
17 + "sync/atomic"
18 + "time"
19 +
20 + "internetpressure.io/probe-agent/internal/client"
21 + "internetpressure.io/probe-agent/internal/protocol"
22 + "internetpressure.io/probe-agent/internal/spool"
23 +)
24 +
25 +// Defaults and limits.
26 +const (
27 + DefaultFlushEvery = 10 * time.Second
28 + DefaultMaxBatch = 500
29 + HealthEvery = time.Minute
30 + BackoffMin = 5 * time.Second
31 + BackoffMax = 5 * time.Minute
32 + FinalFlushTimeout = 5 * time.Second
33 + drainPerCycle = 20
34 +)
35 +
36 +// Poster is the subset of client.Client used here (interface for tests).
37 +type Poster interface {
38 + PostBatch(ctx context.Context, gz []byte, timeout time.Duration) (*protocol.BatchResponse, error)
39 +}
40 +
41 +// Hooks are optional callbacks.
42 +type Hooks struct {
43 + Health func() *protocol.Health // builds the health block (called ≤ once/minute)
44 + OnResponse func(*protocol.BatchResponse)
45 + OnFlushFailure func()
46 + OnDelivered func(t time.Time)
47 +}
48 +
49 +// Batcher is the queue + flusher.
50 +type Batcher struct {
51 + probeID string
52 + version string
53 + poster Poster
54 + spool *spool.Spool
55 + hooks Hooks
56 + log *slog.Logger
57 +
58 + flushEvery atomic.Int64 // ns
59 + maxBatch atomic.Int32
60 +
61 + mu sync.Mutex
62 + meas []protocol.Measurement
63 + trs []protocol.Traceroute
64 + kick chan struct{}
65 + timer *time.Timer
66 +
67 + lastHealth time.Time
68 + backoff time.Duration
69 + nextTry time.Time
70 + stopped atomic.Bool
71 +}
72 +
73 +// New creates a Batcher.
74 +func New(probeID, version string, poster Poster, sp *spool.Spool, hooks Hooks, log *slog.Logger) *Batcher {
75 + if log == nil {
76 + log = slog.Default()
77 + }
78 + b := &Batcher{probeID: probeID, version: version, poster: poster, spool: sp, hooks: hooks, log: log,
79 + kick: make(chan struct{}, 1)}
80 + b.flushEvery.Store(int64(DefaultFlushEvery))
81 + b.maxBatch.Store(DefaultMaxBatch)
82 + return b
83 +}
84 +
85 +// Configure updates flush interval / batch size (from the remote schedule). Zero keeps the default.
86 +func (b *Batcher) Configure(flushSeconds, maxBatch int) {
87 + if flushSeconds > 0 {
88 + b.flushEvery.Store(int64(time.Duration(flushSeconds) * time.Second))
89 + }
90 + if maxBatch > 0 {
91 + b.maxBatch.Store(int32(maxBatch))
92 + }
93 +}
94 +
95 +// Add queues measurements; triggers an immediate flush when max_batch is reached.
96 +func (b *Batcher) Add(ms ...protocol.Measurement) {
97 + if len(ms) == 0 {
98 + return
99 + }
100 + b.mu.Lock()
101 + b.meas = append(b.meas, ms...)
102 + full := len(b.meas) >= int(b.maxBatch.Load())
103 + b.mu.Unlock()
104 + if full {
105 + b.Kick()
106 + }
107 +}
108 +
109 +// AddTraceroute queues a traceroute.
110 +func (b *Batcher) AddTraceroute(t protocol.Traceroute) {
111 + b.mu.Lock()
112 + b.trs = append(b.trs, t)
113 + b.mu.Unlock()
114 +}
115 +
116 +// Kick requests a flush as soon as possible.
117 +func (b *Batcher) Kick() {
118 + select {
119 + case b.kick <- struct{}{}:
120 + default:
121 + }
122 +}
123 +
124 +// Buffered returns the number of queued measurements + traceroutes.
125 +func (b *Batcher) Buffered() int {
126 + b.mu.Lock()
127 + defer b.mu.Unlock()
128 + return len(b.meas) + len(b.trs)
129 +}
130 +
131 +// Run flushes on the timer and on kicks until ctx is cancelled. The caller then invokes Stop once the
132 +// producers (scheduler) have finished, so the final flush contains every completed measurement.
133 +func (b *Batcher) Run(ctx context.Context) {
134 + for {
135 + wait := time.Duration(b.flushEvery.Load())
136 + select {
137 + case <-ctx.Done():
138 + return
139 + case <-b.kick:
140 + case <-time.After(wait):
141 + }
142 + b.Flush(ctx)
143 + }
144 +}
145 +
146 +// Stop performs the graceful final flush: one attempt with a 5 s timeout, everything else spooled.
147 +func (b *Batcher) Stop() {
148 + if !b.stopped.CompareAndSwap(false, true) {
149 + return
150 + }
151 + ctx, cancel := context.WithTimeout(context.Background(), FinalFlushTimeout)
152 + defer cancel()
153 + for b.Buffered() > 0 {
154 + body, n := b.take()
155 + if n == 0 {
156 + break
157 + }
158 + gz, err := encode(body)
159 + if err != nil {
160 + b.log.Error("final flush: encode", "err", err)
161 + return
162 + }
163 + if _, err := b.poster.PostBatch(ctx, gz, FinalFlushTimeout); err != nil {
164 + b.toSpool(gz, "final flush failed: "+err.Error())
165 + // After one failure, spool the rest without trying the network again.
166 + for b.Buffered() > 0 {
167 + body, n := b.take()
168 + if n == 0 {
169 + break
170 + }
171 + if gz, err := encode(body); err == nil {
172 + b.toSpool(gz, "final flush: spooled remainder")
173 + }
174 + }
175 + return
176 + }
177 + b.log.Info("final flush delivered", "measurements", len(body.Measurements), "traceroutes", len(body.Traceroutes))
178 + }
179 +}
180 +
181 +// Flush runs one delivery cycle: drain the spool (oldest first), then send the live queue.
182 +func (b *Batcher) Flush(ctx context.Context) {
183 + now := time.Now()
184 + inBackoff := b.backoff > 0 && now.Before(b.nextTry)
185 +
186 + if !inBackoff && b.spool != nil {
187 + b.drainSpool(ctx)
188 + inBackoff = b.backoff > 0 && time.Now().Before(b.nextTry)
189 + }
190 +
191 + for {
192 + body, n := b.take()
193 + if n == 0 {
194 + return
195 + }
196 + gz, err := encode(body)
197 + if err != nil {
198 + b.log.Error("batch encode failed, dropping", "err", err, "n", n)
199 + return
200 + }
201 + if inBackoff {
202 + b.toSpool(gz, "server unavailable (backoff)")
203 + } else if !b.send(ctx, gz, body) {
204 + inBackoff = true
205 + }
206 + // Keep going until the queue is empty (in max_batch-sized chunks).
207 + b.mu.Lock()
208 + more := len(b.meas) > 0 || len(b.trs) > 0
209 + b.mu.Unlock()
210 + if !more {
211 + return
212 + }
213 + }
214 +}
215 +
216 +// take removes up to max_batch measurements (and traceroutes) from the queue and builds the batch body,
217 +// attaching the health block when due. Returns (body, number of items taken).
218 +func (b *Batcher) take() (*protocol.Batch, int) {
219 + max := int(b.maxBatch.Load())
220 + b.mu.Lock()
221 + nm := len(b.meas)
222 + if nm > max {
223 + nm = max
224 + }
225 + nt := len(b.trs)
226 + if nt > max {
227 + nt = max
228 + }
229 + body := &protocol.Batch{ProbeID: b.probeID, AgentVersion: b.version,
230 + Measurements: make([]protocol.Measurement, nm)}
231 + copy(body.Measurements, b.meas[:nm])
232 + b.meas = append(b.meas[:0:0], b.meas[nm:]...) // fresh backing array so memory is released
233 + if nt > 0 {
234 + body.Traceroutes = make([]protocol.Traceroute, nt)
235 + copy(body.Traceroutes, b.trs[:nt])
236 + b.trs = append(b.trs[:0:0], b.trs[nt:]...)
237 + }
238 + b.mu.Unlock()
239 +
240 + healthDue := b.hooks.Health != nil && time.Since(b.lastHealth) >= HealthEvery
241 + if nm+nt == 0 && !healthDue {
242 + return nil, 0
243 + }
244 + if healthDue {
245 + body.Health = b.hooks.Health()
246 + b.lastHealth = time.Now()
247 + }
248 + body.SentAt = protocol.FormatTime(time.Now())
249 + n := nm + nt
250 + if n == 0 {
251 + n = 1 // health-only batch
252 + }
253 + return body, n
254 +}
255 +
256 +// encode marshals + gzips a batch.
257 +func encode(body *protocol.Batch) ([]byte, error) {
258 + raw, err := json.Marshal(body)
259 + if err != nil {
260 + return nil, err
261 + }
262 + var buf bytes.Buffer
263 + zw, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed)
264 + if _, err := zw.Write(raw); err != nil {
265 + return nil, err
266 + }
267 + if err := zw.Close(); err != nil {
268 + return nil, err
269 + }
270 + return buf.Bytes(), nil
271 +}
272 +
273 +// send posts one live batch. Returns false when the server is unavailable (batch spooled, backoff set).
274 +func (b *Batcher) send(ctx context.Context, gz []byte, body *protocol.Batch) bool {
275 + resp, err := b.poster.PostBatch(ctx, gz, client.BatchTimeout)
276 + if err != nil {
277 + var he *client.HTTPError
278 + if errors.As(err, &he) && he.IsSkew() {
279 + // The client resynced its clock from the response; retry once with a fresh signature.
280 + b.log.Warn("batch rejected for clock skew, resynced and retrying once", "err", err)
281 + resp, err = b.poster.PostBatch(ctx, gz, client.BatchTimeout)
282 + }
283 + }
284 + if err != nil {
285 + if client.Retryable(err) {
286 + b.fail(err)
287 + b.toSpool(gz, err.Error())
288 + return false
289 + }
290 + b.log.Error("batch rejected, dropping", "err", err, "measurements", len(body.Measurements))
291 + return true
292 + }
293 + b.success(resp, len(body.Measurements), len(body.Traceroutes))
294 + return true
295 +}
296 +
297 +// drainSpool re-sends spooled batches oldest first until empty, a failure, or drainPerCycle files.
298 +func (b *Batcher) drainSpool(ctx context.Context) {
299 + for i := 0; i < drainPerCycle; i++ {
300 + name, gz, err := b.spool.Oldest()
301 + if err != nil {
302 + return
303 + }
304 + resp, err := b.poster.PostBatch(ctx, gz, client.BatchTimeout)
305 + if err != nil {
306 + var he *client.HTTPError
307 + if errors.As(err, &he) && he.IsSkew() {
308 + resp, err = b.poster.PostBatch(ctx, gz, client.BatchTimeout)
309 + }
310 + }
311 + if err != nil {
312 + if client.Retryable(err) {
313 + b.fail(err)
314 + return
315 + }
316 + b.log.Error("spooled batch rejected, dropping", "file", name, "err", err)
317 + b.spool.Remove(name)
318 + continue
319 + }
320 + b.spool.Remove(name)
321 + b.success(resp, -1, -1)
322 + b.log.Info("spooled batch delivered", "file", name, "accepted", resp.Accepted, "rejected", resp.Rejected)
323 + }
324 +}
325 +
326 +func (b *Batcher) toSpool(gz []byte, why string) {
327 + if b.spool == nil {
328 + b.log.Error("no spool, batch lost", "why", why)
329 + return
330 + }
331 + name, err := b.spool.Write(gz)
332 + if err != nil {
333 + b.log.Error("spool write failed, batch lost", "err", err, "why", why)
334 + return
335 + }
336 + b.log.Warn("batch spooled", "file", name, "bytes", len(gz), "why", why)
337 +}
338 +
339 +func (b *Batcher) fail(err error) {
340 + if b.backoff == 0 {
341 + b.backoff = BackoffMin
342 + } else {
343 + b.backoff *= 2
344 + if b.backoff > BackoffMax {
345 + b.backoff = BackoffMax
346 + }
347 + }
348 + b.nextTry = time.Now().Add(b.backoff)
349 + if b.hooks.OnFlushFailure != nil {
350 + b.hooks.OnFlushFailure()
351 + }
352 + b.log.Warn("batch delivery failed", "err", err, "retry_in", b.backoff)
353 +}
354 +
355 +func (b *Batcher) success(resp *protocol.BatchResponse, nm, nt int) {
356 + if b.backoff > 0 {
357 + b.log.Info("server reachable again")
358 + }
359 + b.backoff = 0
360 + if b.hooks.OnDelivered != nil {
361 + b.hooks.OnDelivered(time.Now())
362 + }
363 + if resp != nil && b.hooks.OnResponse != nil {
364 + b.hooks.OnResponse(resp)
365 + }
366 + if nm >= 0 {
367 + b.log.Debug("batch delivered", "measurements", nm, "traceroutes", nt, "accepted", resp.Accepted, "rejected", resp.Rejected)
368 + }
369 +}
370 +
371 +// InBackoff reports whether deliveries are currently suspended.
372 +func (b *Batcher) InBackoff() bool { return b.backoff > 0 && time.Now().Before(b.nextTry) }
added services/probe-agent/internal/batcher/batcher_test.go +209 −0
@@ -0,0 +1,209 @@
1 +package batcher
2 +
3 +import (
4 + "bytes"
5 + "compress/gzip"
6 + "context"
7 + "encoding/json"
8 + "errors"
9 + "log/slog"
10 + "sync"
11 + "testing"
12 + "time"
13 +
14 + "internetpressure.io/probe-agent/internal/client"
15 + "internetpressure.io/probe-agent/internal/protocol"
16 + "internetpressure.io/probe-agent/internal/spool"
17 +)
18 +
19 +// fakePoster records batches and can be switched to failing.
20 +type fakePoster struct {
21 + mu sync.Mutex
22 + batches []protocol.Batch
23 + fail error
24 + calls int
25 +}
26 +
27 +func (p *fakePoster) PostBatch(_ context.Context, gz []byte, _ time.Duration) (*protocol.BatchResponse, error) {
28 + p.mu.Lock()
29 + defer p.mu.Unlock()
30 + p.calls++
31 + if p.fail != nil {
32 + return nil, p.fail
33 + }
34 + zr, err := gzip.NewReader(bytes.NewReader(gz))
35 + if err != nil {
36 + return nil, err
37 + }
38 + var b protocol.Batch
39 + if err := json.NewDecoder(zr).Decode(&b); err != nil {
40 + return nil, err
41 + }
42 + p.batches = append(p.batches, b)
43 + return &protocol.BatchResponse{Accepted: len(b.Measurements), ConfigVersion: "v1", ServerTime: protocol.FormatTime(time.Now())}, nil
44 +}
45 +
46 +func (p *fakePoster) setFail(err error) { p.mu.Lock(); p.fail = err; p.mu.Unlock() }
47 +func (p *fakePoster) count() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.batches) }
48 +
49 +func newBatcher(t *testing.T, p Poster, hooks Hooks) (*Batcher, *spool.Spool) {
50 + sp, err := spool.Open(t.TempDir(), 0)
51 + if err != nil {
52 + t.Fatal(err)
53 + }
54 + return New("ca-qc-01", "0.1.0", p, sp, hooks, slog.New(slog.DiscardHandler)), sp
55 +}
56 +
57 +func meas(n int) []protocol.Measurement {
58 + out := make([]protocol.Measurement, n)
59 + for i := range out {
60 + out[i] = protocol.Measurement{TS: protocol.FormatTime(time.Now()), TargetID: "t", Kind: "http", OK: true}
61 + }
62 + return out
63 +}
64 +
65 +func TestBatchSizeLimit(t *testing.T) {
66 + p := &fakePoster{}
67 + b, _ := newBatcher(t, p, Hooks{})
68 + b.Configure(10, 500)
69 + b.Add(meas(1200)...)
70 + b.AddTraceroute(protocol.Traceroute{TargetID: "t"})
71 + b.Flush(context.Background())
72 + if b.Buffered() != 0 {
73 + t.Fatalf("queue should be drained, has %d", b.Buffered())
74 + }
75 + if p.count() != 3 {
76 + t.Fatalf("1200 measurements should give 3 batches, got %d", p.count())
77 + }
78 + for i, batch := range p.batches {
79 + if len(batch.Measurements) > 500 {
80 + t.Fatalf("batch %d has %d > max_batch", i, len(batch.Measurements))
81 + }
82 + if batch.ProbeID != "ca-qc-01" || batch.AgentVersion != "0.1.0" || batch.SentAt == "" {
83 + t.Fatalf("envelope: %+v", batch)
84 + }
85 + }
86 + if len(p.batches[0].Measurements) != 500 || len(p.batches[2].Measurements) != 200 || len(p.batches[0].Traceroutes) != 1 {
87 + t.Fatalf("split: %d %d %d, trs %d", len(p.batches[0].Measurements), len(p.batches[1].Measurements), len(p.batches[2].Measurements), len(p.batches[0].Traceroutes))
88 + }
89 +}
90 +
91 +func TestHealthOncePerMinute(t *testing.T) {
92 + p := &fakePoster{}
93 + calls := 0
94 + b, _ := newBatcher(t, p, Hooks{Health: func() *protocol.Health { calls++; return &protocol.Health{AgentVersion: "0.1.0"} }})
95 + b.Add(meas(1)...)
96 + b.Flush(context.Background())
97 + b.Add(meas(1)...)
98 + b.Flush(context.Background())
99 + if calls != 1 || p.batches[0].Health == nil || p.batches[1].Health != nil {
100 + t.Fatalf("health should be attached once: calls=%d", calls)
101 + }
102 + // Empty queue and health not due → no POST at all.
103 + n := p.calls
104 + b.Flush(context.Background())
105 + if p.calls != n {
106 + t.Fatal("empty flush must not POST")
107 + }
108 +}
109 +
110 +func TestSpoolOnFailureAndDrain(t *testing.T) {
111 + p := &fakePoster{}
112 + failures := 0
113 + b, sp := newBatcher(t, p, Hooks{OnFlushFailure: func() { failures++ }})
114 + p.setFail(&client.HTTPError{Status: 503, Body: "down"})
115 + b.Add(meas(10)...)
116 + b.Flush(context.Background())
117 + if n, _ := sp.Stats(); n != 1 || failures != 1 || !b.InBackoff() {
118 + t.Fatalf("expected 1 spooled file + backoff, got %d files, failures=%d", n, failures)
119 + }
120 + // During backoff new batches go straight to the spool without touching the network.
121 + calls := p.calls
122 + b.Add(meas(5)...)
123 + b.Flush(context.Background())
124 + if p.calls != calls {
125 + t.Fatal("must not POST during backoff")
126 + }
127 + if n, _ := sp.Stats(); n != 2 {
128 + t.Fatalf("expected 2 spooled files, got %d", n)
129 + }
130 + // Server back: force backoff expiry, drain oldest first, then live.
131 + p.setFail(nil)
132 + b.nextTry = time.Now().Add(-time.Second)
133 + b.Add(meas(3)...)
134 + b.Flush(context.Background())
135 + if n, _ := sp.Stats(); n != 0 {
136 + t.Fatalf("spool should be drained, %d left", n)
137 + }
138 + if p.count() != 3 || len(p.batches[0].Measurements) != 10 || len(p.batches[1].Measurements) != 5 || len(p.batches[2].Measurements) != 3 {
139 + t.Fatalf("delivery order wrong: %d batches", p.count())
140 + }
141 + if b.InBackoff() {
142 + t.Fatal("backoff should be cleared after success")
143 + }
144 +}
145 +
146 +func TestBackoffGrowsAndCaps(t *testing.T) {
147 + b, _ := newBatcher(t, &fakePoster{}, Hooks{})
148 + want := []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second, 80 * time.Second, 160 * time.Second, 300 * time.Second, 300 * time.Second}
149 + for i, w := range want {
150 + b.fail(errors.New("x"))
151 + if b.backoff != w {
152 + t.Fatalf("step %d: backoff %v want %v", i, b.backoff, w)
153 + }
154 + }
155 +}
156 +
157 +func TestAuthFailureDrops(t *testing.T) {
158 + p := &fakePoster{}
159 + b, sp := newBatcher(t, p, Hooks{})
160 + p.setFail(&client.HTTPError{Status: 401, Body: `{"detail":"bad signature"}`})
161 + b.Add(meas(2)...)
162 + b.Flush(context.Background())
163 + if n, _ := sp.Stats(); n != 0 || b.InBackoff() || b.Buffered() != 0 {
164 + t.Fatalf("401 must drop without spool/backoff: files=%d backoff=%v buffered=%d", n, b.InBackoff(), b.Buffered())
165 + }
166 + // 401 skew → retried once.
167 + p.setFail(&client.HTTPError{Status: 401, Body: `{"detail":"timestamp skew"}`})
168 + calls := p.calls
169 + b.Add(meas(2)...)
170 + b.Flush(context.Background())
171 + if p.calls-calls != 2 {
172 + t.Fatalf("skew should retry exactly once, got %d calls", p.calls-calls)
173 + }
174 +}
175 +
176 +func TestStopSpoolsRemainder(t *testing.T) {
177 + p := &fakePoster{}
178 + b, sp := newBatcher(t, p, Hooks{})
179 + b.Configure(10, 5)
180 + p.setFail(errors.New("dial tcp: connection refused"))
181 + b.Add(meas(12)...)
182 + b.Stop()
183 + if b.Buffered() != 0 {
184 + t.Fatalf("queue not emptied on stop: %d", b.Buffered())
185 + }
186 + if n, _ := sp.Stats(); n != 3 {
187 + t.Fatalf("expected 3 spooled files on stop, got %d", n)
188 + }
189 + if p.calls != 1 {
190 + t.Fatalf("stop should try the network once, tried %d", p.calls)
191 + }
192 +}
193 +
194 +func TestMaxBatchKicks(t *testing.T) {
195 + p := &fakePoster{}
196 + b, _ := newBatcher(t, p, Hooks{})
197 + b.Configure(3600, 4)
198 + ctx, cancel := context.WithCancel(context.Background())
199 + defer cancel()
200 + go b.Run(ctx)
201 + b.Add(meas(4)...)
202 + deadline := time.Now().Add(2 * time.Second)
203 + for p.count() == 0 && time.Now().Before(deadline) {
204 + time.Sleep(10 * time.Millisecond)
205 + }
206 + if p.count() != 1 {
207 + t.Fatal("reaching max_batch should flush immediately")
208 + }
209 +}
added services/probe-agent/internal/checks/dns/dns.go +176 −0
@@ -0,0 +1,176 @@
1 +// Package dns implements the "dns" check: one A query per configured resolver, run sequentially.
2 +//
3 +// Resolver "system" (empty address) uses the OS resolver through net.DefaultResolver; every other resolver is
4 +// queried directly over UDP with github.com/miekg/dns (EDNS0, 3 s timeout), retried once over TCP only when the
5 +// UDP answer is truncated. Exactly one query per resolver per check.
6 +package dns
7 +
8 +import (
9 + "context"
10 + "errors"
11 + "net"
12 + "sort"
13 + "strings"
14 + "time"
15 +
16 + mdns "github.com/miekg/dns"
17 +
18 + "internetpressure.io/probe-agent/internal/protocol"
19 +)
20 +
21 +// Timeout per query.
22 +const Timeout = 3 * time.Second
23 +
24 +// DefaultResolvers is used by `once` and when the server sends none.
25 +var DefaultResolvers = []protocol.Resolver{
26 + {ID: "system", Address: ""},
27 + {ID: "google", Address: "8.8.8.8:53"},
28 + {ID: "cloudflare", Address: "1.1.1.1:53"},
29 + {ID: "quad9", Address: "9.9.9.9:53"},
30 +}
31 +
32 +// Checker runs dns checks.
33 +type Checker struct{}
34 +
35 +// Run queries every resolver for the A record of t.Hostname and returns one measurement per resolver.
36 +func (c *Checker) Run(ctx context.Context, t protocol.Target, resolvers []protocol.Resolver) []protocol.Measurement {
37 + if len(resolvers) == 0 {
38 + resolvers = DefaultResolvers
39 + }
40 + out := make([]protocol.Measurement, 0, len(resolvers))
41 + for _, r := range resolvers {
42 + if ctx.Err() != nil {
43 + break
44 + }
45 + out = append(out, c.query(ctx, t, r))
46 + }
47 + return out
48 +}
49 +
50 +func (c *Checker) query(ctx context.Context, t protocol.Target, r protocol.Resolver) protocol.Measurement {
51 + m := protocol.Measurement{TS: protocol.FormatTime(time.Now()), TargetID: t.TargetID, Kind: "dns", Resolver: r.ID}
52 + ctx, cancel := context.WithTimeout(ctx, Timeout)
53 + defer cancel()
54 +
55 + start := time.Now()
56 + var answers []string
57 + var rcode string
58 + var err error
59 + if r.Address == "" || r.ID == "system" {
60 + answers, rcode, err = querySystem(ctx, t.Hostname)
61 + } else {
62 + answers, rcode, err = queryServer(ctx, t.Hostname, r.Address)
63 + }
64 + m.DNSMs = protocol.F(protocol.Ms(time.Since(start)))
65 + m.DNSRcode = rcode
66 + sort.Strings(answers)
67 + m.DNSAnswers = answers
68 +
69 + switch {
70 + case err != nil:
71 + m.Error = classify(err, rcode)
72 + case rcode == "NOERROR" && len(answers) > 0:
73 + m.OK = true
74 + case rcode == "NOERROR":
75 + m.Error = protocol.ErrDNSFail // NOERROR but no A record (e.g. AAAA-only or CNAME chain without A)
76 + default:
77 + m.Error = classify(nil, rcode)
78 + }
79 + return m
80 +}
81 +
82 +// querySystem resolves through the OS stub resolver. The rcode is inferred from the error class.
83 +func querySystem(ctx context.Context, host string) ([]string, string, error) {
84 + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", host)
85 + if err != nil {
86 + var dnsErr *net.DNSError
87 + if errors.As(err, &dnsErr) {
88 + switch {
89 + case dnsErr.IsNotFound:
90 + return nil, "NXDOMAIN", err
91 + case dnsErr.IsTimeout || errors.Is(err, context.DeadlineExceeded):
92 + return nil, "TIMEOUT", err
93 + default:
94 + return nil, "SERVFAIL", err
95 + }
96 + }
97 + if errors.Is(err, context.DeadlineExceeded) {
98 + return nil, "TIMEOUT", err
99 + }
100 + return nil, "ERROR", err
101 + }
102 + out := make([]string, 0, len(ips))
103 + for _, ip := range ips {
104 + if v4 := ip.To4(); v4 != nil {
105 + out = append(out, v4.String())
106 + }
107 + }
108 + return out, "NOERROR", nil
109 +}
110 +
111 +// queryServer sends one A query over UDP (EDNS0 1232) and retries once over TCP if truncated.
112 +func queryServer(ctx context.Context, host, server string) ([]string, string, error) {
113 + msg := new(mdns.Msg)
114 + msg.SetQuestion(mdns.Fqdn(host), mdns.TypeA)
115 + msg.RecursionDesired = true
116 + msg.SetEdns0(1232, false)
117 +
118 + client := &mdns.Client{Net: "udp", Timeout: Timeout, UDPSize: 1232}
119 + resp, _, err := client.ExchangeContext(ctx, msg, server)
120 + if err == nil && resp != nil && resp.Truncated {
121 + tcp := &mdns.Client{Net: "tcp", Timeout: Timeout}
122 + resp, _, err = tcp.ExchangeContext(ctx, msg, server)
123 + }
124 + if err != nil {
125 + var ne net.Error
126 + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) ||
127 + strings.Contains(err.Error(), "i/o timeout") {
128 + return nil, "TIMEOUT", err
129 + }
130 + return nil, "ERROR", err
131 + }
132 + rcode := mdns.RcodeToString[resp.Rcode]
133 + if rcode == "" {
134 + rcode = "RCODE" + itoa(resp.Rcode)
135 + }
136 + var out []string
137 + for _, rr := range resp.Answer {
138 + if a, ok := rr.(*mdns.A); ok {
139 + out = append(out, a.A.String())
140 + }
141 + }
142 + return out, rcode, nil
143 +}
144 +
145 +// classify maps (transport error, rcode) to a protocol error code.
146 +func classify(err error, rcode string) string {
147 + switch rcode {
148 + case "TIMEOUT":
149 + return protocol.ErrDNSTimeout
150 + case "SERVFAIL":
151 + return protocol.ErrDNSServfail
152 + case "NXDOMAIN":
153 + return protocol.ErrDNSNxdomain
154 + }
155 + if err != nil {
156 + var ne net.Error
157 + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) {
158 + return protocol.ErrDNSTimeout
159 + }
160 + }
161 + return protocol.ErrDNSFail
162 +}
163 +
164 +func itoa(n int) string {
165 + if n == 0 {
166 + return "0"
167 + }
168 + var b [20]byte
169 + i := len(b)
170 + for n > 0 {
171 + i--
172 + b[i] = byte('0' + n%10)
173 + n /= 10
174 + }
175 + return string(b[i:])
176 +}
added services/probe-agent/internal/checks/dns/dns_test.go +105 −0
@@ -0,0 +1,105 @@
1 +package dns
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "net"
7 + "testing"
8 + "time"
9 +
10 + mdns "github.com/miekg/dns"
11 +
12 + "internetpressure.io/probe-agent/internal/protocol"
13 +)
14 +
15 +type timeoutErr struct{}
16 +
17 +func (timeoutErr) Error() string { return "i/o timeout" }
18 +func (timeoutErr) Timeout() bool { return true }
19 +func (timeoutErr) Temporary() bool { return true }
20 +
21 +func TestClassify(t *testing.T) {
22 + cases := []struct {
23 + err error
24 + rcode string
25 + want string
26 + }{
27 + {nil, "TIMEOUT", protocol.ErrDNSTimeout},
28 + {nil, "SERVFAIL", protocol.ErrDNSServfail},
29 + {nil, "NXDOMAIN", protocol.ErrDNSNxdomain},
30 + {nil, "REFUSED", protocol.ErrDNSFail},
31 + {timeoutErr{}, "ERROR", protocol.ErrDNSTimeout},
32 + {context.DeadlineExceeded, "", protocol.ErrDNSTimeout},
33 + {errors.New("boom"), "ERROR", protocol.ErrDNSFail},
34 + }
35 + for _, c := range cases {
36 + if got := classify(c.err, c.rcode); got != c.want {
37 + t.Errorf("classify(%v,%q)=%q want %q", c.err, c.rcode, got, c.want)
38 + }
39 + }
40 +}
41 +
42 +// A tiny in-process authoritative server answers example.test with two A records and NXDOMAIN otherwise.
43 +func startServer(t *testing.T) string {
44 + pc, err := net.ListenPacket("udp", "127.0.0.1:0")
45 + if err != nil {
46 + t.Fatal(err)
47 + }
48 + srv := &mdns.Server{PacketConn: pc, Handler: mdns.HandlerFunc(func(w mdns.ResponseWriter, r *mdns.Msg) {
49 + m := new(mdns.Msg)
50 + m.SetReply(r)
51 + switch r.Question[0].Name {
52 + case "example.test.":
53 + m.Answer = append(m.Answer,
54 + &mdns.A{Hdr: mdns.RR_Header{Name: "example.test.", Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: 60}, A: net.ParseIP("10.0.0.2")},
55 + &mdns.A{Hdr: mdns.RR_Header{Name: "example.test.", Rrtype: mdns.TypeA, Class: mdns.ClassINET, Ttl: 60}, A: net.ParseIP("10.0.0.1")})
56 + case "fail.test.":
57 + m.Rcode = mdns.RcodeServerFailure
58 + case "slow.test.":
59 + return // never answers → timeout
60 + default:
61 + m.Rcode = mdns.RcodeNameError
62 + }
63 + _ = w.WriteMsg(m)
64 + })}
65 + go srv.ActivateAndServe()
66 + t.Cleanup(func() { srv.Shutdown() })
67 + return pc.LocalAddr().String()
68 +}
69 +
70 +func TestQueryServer(t *testing.T) {
71 + addr := startServer(t)
72 + c := &Checker{}
73 + res := []protocol.Resolver{{ID: "local", Address: addr}}
74 +
75 + ms := c.Run(context.Background(), protocol.Target{TargetID: "t", Hostname: "example.test"}, res)
76 + if len(ms) != 1 {
77 + t.Fatalf("expected 1 measurement, got %d", len(ms))
78 + }
79 + m := ms[0]
80 + if !m.OK || m.Error != "" || m.DNSRcode != "NOERROR" || m.Resolver != "local" || m.Kind != "dns" {
81 + t.Fatalf("unexpected: %+v", m)
82 + }
83 + if len(m.DNSAnswers) != 2 || m.DNSAnswers[0] != "10.0.0.1" || m.DNSAnswers[1] != "10.0.0.2" {
84 + t.Fatalf("answers not sorted: %v", m.DNSAnswers)
85 + }
86 + if m.DNSMs == nil || *m.DNSMs < 0 {
87 + t.Fatalf("dns_ms missing")
88 + }
89 +
90 + m = c.Run(context.Background(), protocol.Target{TargetID: "t", Hostname: "nope.test"}, res)[0]
91 + if m.OK || m.Error != protocol.ErrDNSNxdomain || m.DNSRcode != "NXDOMAIN" {
92 + t.Fatalf("nxdomain: %+v", m)
93 + }
94 + m = c.Run(context.Background(), protocol.Target{TargetID: "t", Hostname: "fail.test"}, res)[0]
95 + if m.OK || m.Error != protocol.ErrDNSServfail || m.DNSRcode != "SERVFAIL" {
96 + t.Fatalf("servfail: %+v", m)
97 + }
98 +
99 + ctx, cancel := context.WithTimeout(context.Background(), 700*time.Millisecond)
100 + defer cancel()
101 + m = c.Run(ctx, protocol.Target{TargetID: "t", Hostname: "slow.test"}, res)[0]
102 + if m.OK || m.Error != protocol.ErrDNSTimeout || m.DNSRcode != "TIMEOUT" {
103 + t.Fatalf("timeout: %+v", m)
104 + }
105 +}
added services/probe-agent/internal/checks/http/http.go +82 −0
@@ -0,0 +1,320 @@
1 +// Package http implements the "http" check: one GET over a fresh connection, timed with net/http/httptrace.
2 +//
3 +// Protocol rules honoured here: keep-alive disabled (so TCP + TLS are measured every time), no proxy, no
4 +// redirects followed (a 3xx is a successful response), body read capped at 16 KiB, exactly one request, no retry.
5 +package http
6 +
7 +import (
8 + "context"
9 + "crypto/tls"
10 + "crypto/x509"
11 + "errors"
12 + "io"
13 + "net"
14 + "net/http"
15 + "net/http/httptrace"
16 + "os"
17 + "strings"
18 + "syscall"
19 + "time"
20 +
21 + "internetpressure.io/probe-agent/internal/protocol"
22 +)
23 +
24 +const (
25 + // Timeout is the whole-check budget.
26 + Timeout = 10 * time.Second
27 + // MaxBody is the number of body bytes read before closing the connection.
28 + MaxBody = 16 * 1024
29 +)
30 +
31 +// Checker runs http checks. UserAgent is sent verbatim.
32 +type Checker struct {
33 + UserAgent string
34 +}
35 +
36 +// testRoots lets tests trust a local CA; nil (system roots) in production.
37 +var testRoots *x509.CertPool
38 +
39 +// stage tracks how far the request got when an error occurred (used to classify timeouts).
40 +type stage int
41 +
42 +const (
43 + stageDNS stage = iota
44 + stageConnect
45 + stageTLS
46 + stageRequest
47 + stageResponse
48 +)
49 +
50 +// Run performs one GET against t.URL. If t.IP is set the connection goes to that IP while the hostname is kept
51 +// for SNI and the Host header.
52 +func (c *Checker) Run(ctx context.Context, t protocol.Target) protocol.Measurement {
53 + m := protocol.Measurement{TS: protocol.FormatTime(time.Now()), TargetID: t.TargetID, Kind: "http"}
54 + rawURL := t.URL
55 + if rawURL == "" {
56 + rawURL = "https://" + t.Hostname + "/"
57 + }
58 +
59 + ctx, cancel := context.WithTimeout(ctx, Timeout)
60 + defer cancel()
61 +
62 + var (
63 + start = time.Now()
64 + dnsStart, dnsDone, connStart, connDone time.Time
65 + tlsStart, tlsDone, gotConn, firstByte time.Time
66 + st = stageDNS
67 + fixedIP = t.FixedIP()
68 + resolvedIP string
69 + tlsState *tls.ConnectionState
70 + )
71 +
72 + dialer := &net.Dialer{Timeout: Timeout}
73 + tr := &http.Transport{
74 + Proxy: nil, // never use a proxy: we measure the path to the target itself
75 + DisableKeepAlives: true,
76 + ForceAttemptHTTP2: true,
77 + MaxIdleConns: 0,
78 + TLSHandshakeTimeout: Timeout,
79 + TLSClientConfig: &tls.Config{ServerName: t.Hostname, MinVersion: tls.VersionTLS12, RootCAs: testRoots},
80 + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
81 + if fixedIP != "" {
82 + _, port, err := net.SplitHostPort(addr)

Diff truncated — file too large.