InternetPressure.io — backend (ingest, engine, BGP, corroboration, API, SSE), Go probe agent, infra, docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.