SPB Git

engine: calibration rule, baseline-aware unavailability, ICMP-filtered hosts; tests for HMAC vector and self-exclusion

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 0a4be38 Browse files

9 changed files +629 −5

modified apps/api/src/internetpressure/engine/compute.py +7 −4
@@ -111,7 +111,7 @@ def probe_signals(ctx: Ctx, cur_rows: list[dict[str, Any]], route_rows: list[dic
111 sigs: list[Sig] = [] 111 sigs: list[Sig] = []
112 rate_scale = float(ctx.eng.get("rate_scale", 0.25)) 112 rate_scale = float(ctx.eng.get("rate_scale", 0.25))
113 loss_scale = float(ctx.eng.get("loss_scale", 0.10)) 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)] 114+ by_target_http: dict[str, list[tuple[str, float, float | None]]] = defaultdict(list) # target → [(probe, ok_ratio, baseline_fail)]
115 by_target_dns: dict[str, dict[str, bool]] = defaultdict(dict) # target → resolver → any ok (across probes) 115 by_target_dns: dict[str, dict[str, bool]] = defaultdict(dict) # target → resolver → any ok (across probes)
116 116
117 for r in cur_rows: 117 for r in cur_rows:
@@ -137,7 +137,7 @@ def probe_signals(ctx: Ctx, cur_rows: list[dict[str, Any]], route_rows: list[dic
137 samples=samples, meta=meta), tags)) 137 samples=samples, meta=meta), tags))
138 138
139 if tags.kind == "http": 139 if tags.kind == "http":
140− by_target_http[tags.target_id].append((tags.probe_id, ok_ratio)) 140+ by_target_http[tags.target_id].append((tags.probe_id, ok_ratio, base.fail_rate if (base and base.samples >= 3) else None))
141 if base: 141 if base:
142 for metric, sid in (("ttfb", "ttfb_z"), ("tcp", "tcp_z")): 142 for metric, sid in (("ttfb", "ttfb_z"), ("tcp", "tcp_z")):
143 cur = r.get(metric) 143 cur = r.get(metric)
@@ -186,7 +186,9 @@ def probe_signals(ctx: Ctx, cur_rows: list[dict[str, Any]], route_rows: list[dic
186 t = ctx.targets.get(target_id) 186 t = ctx.targets.get(target_id)
187 if not t: 187 if not t:
188 continue 188 continue
189− failing = [(p, ok) for p, ok in obs if ok < 0.5] 189+ # a pair that normally fails (baseline failure rate ≥ 50 %: bot-blocked or unreachable from that vantage point)
190+ # is not "down" — only a change from the norm is availability pressure
191+ failing = [(p, ok) for p, ok, bf in obs if ok < 0.5 and not (bf is not None and bf >= 0.5)]
190 regions_failing = {ctx.probes[p]["region"] for p, _ in failing if p in ctx.probes} 192 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 193 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 194 # weighted mean over targets = amp·Σimp(down) / (amp·Σimp(down) + Σimp(up)) ≈ amp × share for small shares
@@ -633,7 +635,8 @@ def latency_matrix(ctx: Ctx, sigs: list[Sig], cur_rows: list[dict[str, Any]]) ->
633 # raw window medians (available even before baselines exist) 635 # 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] 636 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] 637 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] 638+ # pairs with 100 % loss are ICMP-filtered destinations, not network loss: excluded from the global loss figure
639+ raw_loss = [float(r["loss"]) for r in cur_rows if r["kind"] in ("ping", "tcp") and r.get("loss") is not None and float(r["loss"]) < 0.99]
637 640
638 def med(v: list[float]) -> float | None: 641 def med(v: list[float]) -> float | None:
639 return round(median(v), 1) if v else None 642 return round(median(v), 1) if v else None
added apps/api/tests/test_ingest_and_health.py +53 −0
@@ -0,0 +1,53 @@
1+"""Cross-checks the Python HMAC verifier against the Go agent's test vector (services/probe-agent README) and the
2+self-exclusion rules."""
3+
4+from internetpressure.engine.health import local_failures
5+from internetpressure.ingest import auth
6+
7+KEY = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
8+TS = "1789189804"
9+BODY = b'{"probe_id":"ca-qc-01","agent_version":"0.1.0","measurements":[]}'
10+
11+
12+def test_go_vector_post():
13+ assert auth.body_sha256(BODY) == "9fd6962cedcf4a7aedb13c38f7f63137fdbbbf3703b447ebd761234981847c65"
14+ assert auth.sign(KEY, "POST", "/ingest/v1/batch", TS, BODY) == "111075ee8d4c20723598c8f75d5ad89433f0385ff965f123302d2b9411280723"
15+
16+
17+def test_go_vector_get():
18+ assert auth.sign(KEY, "GET", "/ingest/v1/config", TS, b"") == "3798599e7f1bed2dab170d2aacf5f5288ec7cc591a64bd37285d3ea3c3450a8b"
19+
20+
21+def test_verify_skew_and_bad_signature():
22+ sig = auth.sign(KEY, "POST", "/ingest/v1/batch", TS, BODY)
23+ assert auth.verify(KEY, "POST", "/ingest/v1/batch", TS, BODY, sig, now=int(TS) + 10) == (True, "ok")
24+ assert auth.verify(KEY, "POST", "/ingest/v1/batch", TS, BODY, sig, now=int(TS) + 1000)[1] == "skew"
25+ assert auth.verify(KEY, "POST", "/ingest/v1/batch", TS, BODY + b" ", sig, now=int(TS))[1] == "bad_signature"
26+ assert auth.verify(KEY, "POST", "/ingest/v1/batch", "abc", BODY, sig, now=int(TS))[1] == "bad_timestamp"
27+ assert auth.verify(KEY, "POST", "/ingest/v1/other", TS, BODY, sig, now=int(TS))[1] == "bad_signature"
28+
29+
30+def _rows(spec):
31+ rows = []
32+ for probe, fail in spec.items():
33+ for i in range(10):
34+ rows.append({"probe_id": probe, "target_id": f"t{i}", "kind": "http", "n": 4, "ok_n": 0 if i < fail else 4})
35+ return rows
36+
37+
38+def test_local_failure_excludes_only_the_broken_probe():
39+ rows = _rows({"a": 9, "b": 0, "c": 1})
40+ excluded, reasons, everyone = local_failures(rows, ["a", "b", "c"], 0.8)
41+ assert excluded == {"a"} and not everyone and "a" in reasons
42+
43+
44+def test_everyone_failing_is_internal_not_internet():
45+ rows = _rows({"a": 10, "b": 9, "c": 10})
46+ excluded, _, everyone = local_failures(rows, ["a", "b", "c"], 0.8)
47+ assert excluded == set() and everyone # cannot exclude anyone: freeze the instrument instead
48+
49+
50+def test_stale_probes_ignored():
51+ rows = _rows({"a": 10, "b": 0})
52+ excluded, _, _ = local_failures(rows, ["b"], 0.8) # "a" is not fresh → not considered
53+ assert excluded == set()
added apps/web/src/app/(site)/incidents/page.tsx +51 −0
@@ -0,0 +1,51 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { IncidentsList } from '@/components/home/IncidentsList';
4+import { IncidentRow } from '@/components/incidents/IncidentRow';
5+import { Empty, Section } from '@/components/ui/primitives';
6+import { apiGet } from '@/lib/api';
7+import { fmtInt } from '@/lib/format';
8+import type { IncidentList } from '@/lib/types';
9+
10+export const dynamic = 'force-dynamic';
11+export const metadata: Metadata = { title: 'Incidents', description: 'Detected, developing, active, recovering and resolved Internet incidents with evidence, hypotheses and confidence.' };
12+
13+export default async function IncidentsPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
14+ const sp = await searchParams;
15+ const page = Math.max(1, Number(sp.page ?? 1) || 1);
16+ const limit = 25;
17+ const [active, resolved] = await Promise.all([apiGet<IncidentList>('/api/v1/incidents?status=active&limit=50'), apiGet<IncidentList>(`/api/v1/incidents?status=resolved&limit=${limit}&offset=${(page - 1) * limit}`)]);
18+ const pages = Math.max(1, Math.ceil(resolved.total / limit));
19+ return (
20+ <div className="pb-8">
21+ <header className="pt-6 pb-4">
22+ <p className="label">Event engine</p>
23+ <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Incidents</h1>
24+ <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">An incident opens when a component or regional score stays above the detection threshold for consecutive cycles, then moves detected → developing → active → recovering → resolved. Confidence reflects probe count, geographic diversity, signal agreement and BGP corroboration. Pages are kept forever.</p>
25+ </header>
26+ <Section label="Active" right={<span>{fmtInt(active.total)} open · live</span>}>
27+ <IncidentsList initial={active.incidents} />
28+ </Section>
29+ <Section label="Resolved" right={<span>{fmtInt(resolved.total)} total</span>}>
30+ {resolved.incidents.length ? (
31+ <ul className="divide-y divide-line">
32+ {resolved.incidents.map((i) => (
33+ <IncidentRow key={i.event_id} inc={i} />
34+ ))}
35+ </ul>
36+ ) : (
37+ <Empty>No resolved incident yet.</Empty>
38+ )}
39+ {pages > 1 && (
40+ <nav className="num mt-4 flex gap-2 text-[12px]" aria-label="Pagination">
41+ {Array.from({ length: pages }, (_, i) => i + 1).map((n) => (
42+ <Link key={n} href={`/incidents?page=${n}`} className={`rounded-[3px] border px-2 py-1 ${n === page ? 'border-line-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`} aria-current={n === page ? 'page' : undefined}>
43+ {n}
44+ </Link>
45+ ))}
46+ </nav>
47+ )}
48+ </Section>
49+ </div>
50+ );
51+}
added apps/web/src/app/(site)/routes/page.tsx +26 −0
@@ -0,0 +1,26 @@
1+import type { Metadata } from 'next';
2+import { RouteExplorer } from '@/components/routes/RouteExplorer';
3+import { apiTry } from '@/lib/api';
4+import type { Probe, RoutePair, RouteResponse, Target } from '@/lib/types';
5+
6+export const dynamic = 'force-dynamic';
7+export const metadata: Metadata = { title: 'Route Explorer', description: 'Probe → ISP → transit → destination: baseline route versus current route, hop by hop, with ASN changes and latency shifts.' };
8+
9+export default async function RoutesPage({ searchParams }: { searchParams: Promise<{ probe?: string; target?: string }> }) {
10+ const sp = await searchParams;
11+ const [pairs, probes, targets] = await Promise.all([apiTry<{ pairs: RoutePair[] }>('/api/v1/routes/pairs'), apiTry<{ probes: Probe[] }>('/api/v1/probes'), apiTry<{ targets: Target[] }>('/api/v1/targets')]);
12+ const list = pairs?.pairs ?? [];
13+ // pre-select: URL → a changed pair → first pair
14+ const initialPair = list.find((p) => p.probe_id === sp.probe && p.target_id === sp.target) ?? [...list].sort((a, b) => b.changed_24h - a.changed_24h)[0] ?? null;
15+ const initialRoute = initialPair ? await apiTry<RouteResponse>(`/api/v1/routes?probe=${encodeURIComponent(initialPair.probe_id)}&target=${encodeURIComponent(initialPair.target_id)}`) : null;
16+ return (
17+ <div className="pb-8">
18+ <header className="pt-6 pb-4">
19+ <p className="label">Path component</p>
20+ <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Route Explorer</h1>
21+ <p className="mt-1 max-w-[760px] text-[13px] text-ink-2">Sampled traceroutes are hashed into route fingerprints. The 7-day dominant fingerprint is the baseline; the current route is compared hop by hop — added or removed hops, ASN path changes and the latency shift they carry.</p>
22+ </header>
23+ <RouteExplorer pairs={list} probes={probes?.probes ?? []} targets={targets?.targets ?? []} initialPair={initialPair} initialRoute={initialRoute} />
24+ </div>
25+ );
26+}
added apps/web/src/app/(site)/service/[slug]/page.tsx +119 −0
@@ -0,0 +1,119 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { ScopeHeader } from '@/components/detail/ScopeHeader';
4+import { ScopePressureChart } from '@/components/detail/ScopeCharts';
5+import { TargetsTable } from '@/components/detail/Tables';
6+import { IncidentsSection } from '@/components/incidents/IncidentsSection';
7+import { ProbeTargetMatrix } from '@/components/service/ProbeTargetMatrix';
8+import { Section, Stat } from '@/components/ui/primitives';
9+import { apiGet, apiTry } from '@/lib/api';
10+import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';
11+import { Time } from '@/lib/time';
12+import type { ServiceDetail } from '@/lib/types';
13+import { VendorIndicator } from '../../services/page';
14+
15+export const dynamic = 'force-dynamic';
16+
17+type Params = Promise<{ slug: string }>;
18+
19+export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
20+ const { slug } = await params;
21+ const s = await apiTry<ServiceDetail>(`/api/v1/service/${encodeURIComponent(slug)}`);
22+ if (!s) return { title: 'Service' };
23+ return { title: `${s.name} — observed pressure ${fmt(s.pressure)}, availability ${fmtPct(s.observed.availability_24h, 2)}`, description: s.discrepancy ?? `Independent observation of ${s.name} from our probe network versus the vendor status page.`, alternates: { canonical: `/service/${s.slug}` } };
24+}
25+
26+export default async function ServicePage({ params }: { params: Params }) {
27+ const { slug } = await params;
28+ const s = await apiGet<ServiceDetail>(`/api/v1/service/${encodeURIComponent(slug)}`);
29+ const ttfbShift = s.observed.ttfb_ms_median_1h - s.observed.ttfb_ms_baseline;
30+ return (
31+ <div className="pb-8">
32+ <ScopeHeader kicker={`Service · ${s.category}`} title={s.name} subtitle={<>{fmtInt(s.targets.length)} endpoints measured from every probe · observed availability 24 h {fmtPct(s.observed.availability_24h, 2)}</>} pressure={s.pressure} level={s.level} />
33+
34+ {s.discrepancy && (
35+ <div role="note" className="mb-4 border-l-2 border-warn bg-[rgba(233,196,106,0.06)] px-4 py-3 text-[13px]">
36+ <p className="label mb-1 text-warn">Discrepancy</p>
37+ <p className="text-ink">{s.discrepancy}</p>
38+ </div>
39+ )}
40+
41+ <div className="grid grid-cols-[minmax(0,1fr)] gap-x-10 md:grid-cols-2">
42+ <Section label="Observed by InternetPressure" className="min-w-0">
43+ <div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
44+ <Stat label="availability 1h" value={<span style={{ color: s.observed.availability_1h < 0.99 ? 'var(--p-high)' : undefined }}>{fmtPct(s.observed.availability_1h, 2)}</span>} />
45+ <Stat label="availability 24h" value={fmtPct(s.observed.availability_24h, 2)} />
46+ <Stat label="failures 1h" value={<span style={{ color: s.observed.failures_1h > 5 ? 'var(--p-elevated)' : undefined }}>{fmtInt(s.observed.failures_1h)}</span>} />
47+ <Stat label="TTFB p50 1h" value={fmtMs(s.observed.ttfb_ms_median_1h)} sub={`baseline ${fmtMs(s.observed.ttfb_ms_baseline)} · ${ttfbShift >= 0 ? '+' : ''}${fmt(ttfbShift, 0)} ms`} />
48+ <Stat label="TLS p50 1h" value={fmtMs(s.observed.tls_ms_median_1h, 1)} />
49+ <Stat label="pressure" value={<span style={{ color: `var(--p-${s.level})` }}>{fmt(s.pressure)}</span>} />
50+ </div>
51+ </Section>
52+ <Section label="Vendor status page" className="min-w-0">
53+ {s.vendor_status ? (
54+ <div className="space-y-2 text-[13px]">
55+ <div className="flex items-center justify-between">
56+ <VendorIndicator v={s.vendor_status} />
57+ <span className="num text-[11px] text-ink-3">
58+ checked <Time ts={s.vendor_status.checked_at} style="time" />
59+ </span>
60+ </div>
61+ <p className="text-ink-2">
62+ {s.vendor_status.incidents ? `${s.vendor_status.incidents} incident${s.vendor_status.incidents > 1 ? 's' : ''} declared` : 'No incident declared'} · source{' '}
63+ {s.vendor_status.url ? (
64+ <a href={s.vendor_status.url} rel="noopener nofollow" target="_blank" className="text-accent hover:underline">
65+ {s.vendor_status.source}
66+ </a>
67+ ) : (
68+ s.vendor_status.source
69+ )}
70+ </p>
71+ {s.vendor_status.titles?.length ? (
72+ <ul className="list-disc space-y-1 pl-4 text-ink">
73+ {s.vendor_status.titles.map((t, i) => (
74+ <li key={i}>{t}</li>
75+ ))}
76+ </ul>
77+ ) : null}
78+ </div>
79+ ) : (
80+ <p className="text-[13px] text-ink-3">No status-page connector for this provider — only our independent observation is shown.</p>
81+ )}
82+ </Section>
83+ </div>
84+
85+ <Section label="Affected regions" right={<span>observed from our probes</span>}>
86+ {s.affected_regions.length ? (
87+ <ul className="divide-y divide-line">
88+ {s.affected_regions.map((r) => (
89+ <li key={r.id} className="flex flex-wrap items-baseline gap-x-4 py-2 text-[13px]">
90+ <Link href={`/internet/${r.id}`} className="w-48 text-ink hover:text-accent">
91+ {r.name}
92+ </Link>
93+ <span className="text-ink-2">{r.observation}</span>
94+ </li>
95+ ))}
96+ </ul>
97+ ) : (
98+ <p className="py-3 text-[12.5px] text-ink-3">No region shows an anomaly toward this service right now.</p>
99+ )}
100+ </Section>
101+
102+ <Section label="Probe × target matrix" right={<span>cell = robust z of TTFB vs baseline · red = failing</span>}>
103+ <ProbeTargetMatrix matrix={s.matrix} targets={s.targets} />
104+ </Section>
105+
106+ <Section label="24 h pressure">
107+ <ScopePressureChart series={s.history_24h} height={220} />
108+ </Section>
109+
110+ <Section label="Incidents">
111+ <IncidentsSection incidents={s.incidents} />
112+ </Section>
113+
114+ <Section label="Endpoints">
115+ <TargetsTable targets={s.targets} />
116+ </Section>
117+ </div>
118+ );
119+}
added apps/web/src/app/(site)/services/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 { fmtInt, fmtPct } from '@/lib/format';
6+import type { ServiceRow } from '@/lib/types';
7+
8+export const dynamic = 'force-dynamic';
9+export const metadata: Metadata = { title: 'Services', description: 'Independent observation of major Internet services versus what their status pages declare.' };
10+
11+export function VendorIndicator({ v }: { v: ServiceRow['vendor_status'] }) {
12+ if (!v) return <span className="text-[11px] text-ink-3">no connector</span>;
13+ const color = v.indicator === 'none' ? 'var(--ok)' : v.indicator === 'minor' ? 'var(--warn)' : 'var(--bad)';
14+ return (
15+ <span className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-[0.08em]" style={{ color }}>
16+ <span className="size-1.5 rounded-full" style={{ background: color }} aria-hidden="true" />
17+ {v.indicator}
18+ {v.incidents ? <span className="num text-ink-2">({v.incidents})</span> : null}
19+ </span>
20+ );
21+}
22+
23+export default async function ServicesPage() {
24+ const { services } = await apiGet<{ services: ServiceRow[] }>('/api/v1/services');
25+ const sorted = [...services].sort((a, b) => b.pressure - a.pressure);
26+ return (
27+ <div className="pb-8">
28+ <header className="pt-6 pb-4">
29+ <p className="label">Index</p>
30+ <h1 className="mt-1 text-[26px] font-medium tracking-tight sm:text-[32px]">Services</h1>
31+ <p className="mt-1 text-[13px] text-ink-2">{fmtInt(services.length)} providers · what we observe from our probes next to what the vendor declares. The discrepancy is the valuable part.</p>
32+ </header>
33+ <Section>
34+ <div className="scroll-x -mx-3 px-3">
35+ <table className="tbl">
36+ <thead>
37+ <tr>
38+ <th>Service</th>
39+ <th className="hidden sm:table-cell">Category</th>
40+ <th className="r">Pressure</th>
41+ <th className="hidden md:table-cell">Level</th>
42+ <th className="r">Observed avail. 24h</th>
43+ <th className="r hidden sm:table-cell">Targets</th>
44+ <th className="hidden md:table-cell">Affected regions</th>
45+ <th>Vendor status</th>
46+ </tr>
47+ </thead>
48+ <tbody>
49+ {sorted.map((s) => {
50+ const discrepancy = s.affected_regions.length > 0 && s.vendor_status?.indicator === 'none';
51+ return (
52+ <tr key={s.slug}>
53+ <td>
54+ <Link href={`/service/${s.slug}`} className="text-ink hover:text-accent">
55+ {s.name}
56+ </Link>
57+ {discrepancy && <span className="ml-2 text-[10px] uppercase tracking-[0.1em] text-warn">discrepancy</span>}
58+ </td>
59+ <td className="hidden text-ink-2 sm:table-cell">{s.category}</td>
60+ <td className="r">
61+ <PNum value={s.pressure} className="text-[14px]" />
62+ </td>
63+ <td className="hidden md:table-cell">
64+ <LevelBadge level={s.level} size="xs" />
65+ </td>
66+ <td className="num r" style={{ color: s.observed_availability_24h < 0.999 ? 'var(--p-elevated)' : 'var(--ink)' }}>
67+ {fmtPct(s.observed_availability_24h, 2)}
68+ </td>
69+ <td className="num r hidden text-ink-2 sm:table-cell">{fmtInt(s.targets)}</td>
70+ <td className="hidden text-ink-2 md:table-cell">{s.affected_regions.length ? s.affected_regions.join(', ') : '—'}</td>
71+ <td>
72+ <VendorIndicator v={s.vendor_status} />
73+ </td>
74+ </tr>
75+ );
76+ })}
77+ </tbody>
78+ </table>
79+ </div>
80+ </Section>
81+ </div>
82+ );
83+}
added apps/web/src/components/routes/RouteExplorer.tsx +233 −0
@@ -0,0 +1,233 @@
1+'use client';
2+
3+import Link from 'next/link';
4+import { useEffect, useMemo, useState } from 'react';
5+import { Bar, Section, Stat } from '@/components/ui/primitives';
6+import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';
7+import { Time, useTime } from '@/lib/time';
8+import type { Hop, Probe, RoutePair, RouteResponse, Target } from '@/lib/types';
9+
10+const HASH_COLORS = ['#5B8DEF', '#E9C46A', '#4CC9F0', '#B497F0', '#F4A261', '#7FB77E', '#E76F51', '#F72585'];
11+
12+function AsnChips({ path, other, label }: { path: number[]; other: number[]; label: string }) {
13+ return (
14+ <div className="flex flex-wrap items-center gap-1.5">
15+ <span className="label mr-1">{label}</span>
16+ {path.map((asn, i) => {
17+ const inOther = other.includes(asn);
18+ return (
19+ <span key={`${asn}-${i}`} className="inline-flex items-center gap-1">
20+ <Link href={`/asn/${asn}`} className={`num rounded-[3px] border px-1.5 py-0.5 text-[11px] ${inOther ? 'border-line text-ink hover:border-line-2' : 'border-warn/60 bg-[rgba(233,196,106,0.1)] text-warn'}`}>
21+ AS{asn}
22+ </Link>
23+ {i < path.length - 1 && <span className="text-ink-3">›</span>}
24+ </span>
25+ );
26+ })}
27+ </div>
28+ );
29+}
30+
31+function HopList({ hops, diffIps, kind, title, total, hash, meta }: { hops: Hop[]; diffIps: Set<string>; kind: 'added' | 'removed'; title: string; total?: number | null; hash: string; meta?: React.ReactNode }) {
32+ return (
33+ <div className="min-w-0">
34+ <div className="mb-2 flex items-baseline justify-between gap-2">
35+ <h3 className="text-[13px] font-medium text-ink">{title}</h3>
36+ <span className="num text-[10.5px] text-ink-3">
37+ {hash.slice(0, 12)} · {hops.length} hops{total != null ? ` · ${fmtMs(total, 1)}` : ''}
38+ </span>
39+ </div>
40+ {meta && <p className="num mb-2 text-[11px] text-ink-3">{meta}</p>}
41+ <ol className="divide-y divide-line border border-line">
42+ {hops.map((h) => {
43+ const flagged = diffIps.has(h.ip);
44+ return (
45+ <li key={`${h.n}-${h.ip}`} className={`grid grid-cols-[28px_minmax(0,1fr)_auto] items-baseline gap-x-3 px-2 py-1.5 text-[12px] ${flagged ? (kind === 'added' ? 'bg-[rgba(231,111,81,0.10)]' : 'bg-[rgba(76,201,240,0.08)]') : ''}`}>
46+ <span className="num text-ink-3">{h.n}</span>
47+ <span className="min-w-0">
48+ <span className={`num ${h.private ? 'text-ink-3' : 'text-ink'}`}>{h.ip}</span>
49+ {h.asn != null ? (
50+ <Link href={`/asn/${h.asn}`} className="ml-2 text-ink-2 hover:text-accent">
51+ AS{h.asn} <span className="hidden text-ink-3 sm:inline">{h.asn_name}</span>
52+ </Link>
53+ ) : (
54+ <span className="ml-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">{h.private ? 'private' : 'unknown'}</span>
55+ )}
56+ {flagged && <span className={`ml-2 text-[10px] uppercase tracking-[0.1em] ${kind === 'added' ? 'text-high' : 'text-calm'}`}>{kind}</span>}
57+ </span>
58+ <span className="num text-ink-2">{h.rtt_ms == null ? '*' : fmtMs(h.rtt_ms, 1)}</span>
59+ </li>
60+ );
61+ })}
62+ </ol>
63+ </div>
64+ );
65+}
66+
67+export function RouteExplorer({ pairs, probes, targets, initialPair, initialRoute }: { pairs: RoutePair[]; probes: Probe[]; targets: Target[]; initialPair: RoutePair | null; initialRoute: RouteResponse | null }) {
68+ const [probe, setProbe] = useState(initialPair?.probe_id ?? '');
69+ const [target, setTarget] = useState(initialPair?.target_id ?? '');
70+ const [route, setRoute] = useState<RouteResponse | null>(initialRoute);
71+ const [loading, setLoading] = useState(false);
72+ const [err, setErr] = useState<string | null>(null);
73+ const { format } = useTime();
74+
75+ const targetName = useMemo(() => new Map(targets.map((t) => [t.target_id, t.name])), [targets]);
76+ const probeName = useMemo(() => new Map(probes.map((p) => [p.probe_id, p.name])), [probes]);
77+ const targetsForProbe = useMemo(() => {
78+ const ids = new Set(pairs.filter((p) => p.probe_id === probe).map((p) => p.target_id));
79+ return targets.filter((t) => ids.has(t.target_id));
80+ }, [pairs, probe, targets]);
81+ const probeIds = useMemo(() => [...new Set(pairs.map((p) => p.probe_id))], [pairs]);
82+
83+ useEffect(() => {
84+ if (!probe || !target) return;
85+ if (route && route.probe.probe_id === probe && route.target.target_id === target) return;
86+ const ctrl = new AbortController();
87+ setLoading(true);
88+ setErr(null);
89+ fetch(`/api/v1/routes?probe=${encodeURIComponent(probe)}&target=${encodeURIComponent(target)}`, { signal: ctrl.signal })
90+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status === 404 ? 'No traceroute sampled for this pair' : `API ${r.status}`))))
91+ .then((d: RouteResponse) => setRoute(d))
92+ .catch((e: Error) => {
93+ if (e.name !== 'AbortError') setErr(e.message);
94+ })
95+ .finally(() => setLoading(false));
96+ return () => ctrl.abort();
97+ // eslint-disable-next-line react-hooks/exhaustive-deps
98+ }, [probe, target]);
99+
100+ const hashColor = useMemo(() => {
101+ const m = new Map<string, string>();
102+ const all = [...(route?.route_share_7d.map((r) => r.route_hash) ?? []), ...(route?.history_24h.map((h) => h.route_hash) ?? [])];
103+ for (const h of all) if (!m.has(h)) m.set(h, HASH_COLORS[m.size % HASH_COLORS.length]!);
104+ return m;
105+ }, [route]);
106+
107+ const added = new Set(route?.diff.added.map((h) => h.ip) ?? []);
108+ const removed = new Set(route?.diff.removed.map((h) => h.ip) ?? []);
109+
110+ return (
111+ <div>
112+ <div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_auto]">
113+ <label className="block min-w-0">
114+ <span className="label">Probe</span>
115+ <select
116+ value={probe}
117+ onChange={(e) => {
118+ setProbe(e.target.value);
119+ const first = pairs.find((p) => p.probe_id === e.target.value);
120+ if (first && !pairs.some((p) => p.probe_id === e.target.value && p.target_id === target)) setTarget(first.target_id);
121+ }}
122+ className="mt-1 h-9 w-full rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink"
123+ >
124+ {probeIds.map((id) => (
125+ <option key={id} value={id}>
126+ {id} — {probeName.get(id) ?? ''}
127+ </option>
128+ ))}
129+ </select>
130+ </label>
131+ <label className="block min-w-0">
132+ <span className="label">Target</span>
133+ <select value={target} onChange={(e) => setTarget(e.target.value)} className="mt-1 h-9 w-full rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink">
134+ {targetsForProbe.map((t) => {
135+ const pr = pairs.find((p) => p.probe_id === probe && p.target_id === t.target_id);
136+ return (
137+ <option key={t.target_id} value={t.target_id}>
138+ {t.name} ({t.hostname}){pr && pr.changed_24h ? ` — ${pr.changed_24h} changes 24h` : ''}
139+ </option>
140+ );
141+ })}
142+ </select>
143+ </label>
144+ <div className="flex items-end">
145+ <p className="num text-[11px] text-ink-3">
146+ {fmtInt(pairs.length)} sampled pairs · {fmtInt(pairs.filter((p) => !p.stable).length)} unstable
147+ </p>
148+ </div>
149+ </div>
150+
151+ <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Changed pairs">
152+ {[...pairs]
153+ .sort((a, b) => b.changed_24h - a.changed_24h)
154+ .slice(0, 10)
155+ .map((p) => {
156+ const active = p.probe_id === probe && p.target_id === target;
157+ return (
158+ <button
159+ key={`${p.probe_id}-${p.target_id}`}
160+ type="button"
161+ onClick={() => {
162+ setProbe(p.probe_id);
163+ setTarget(p.target_id);
164+ }}
165+ className={`num rounded-[3px] border px-2 py-1 text-[11px] ${active ? 'border-line-2 bg-panel-2 text-ink' : 'border-line text-ink-2 hover:text-ink'}`}
166+ >
167+ {p.probe_id} → {targetName.get(p.target_id)?.replace(/ · .*/, '') ?? p.target_id}
168+ <span className={p.changed_24h ? 'ml-1.5 text-warn' : 'ml-1.5 text-ink-3'}>{p.changed_24h ? `${p.changed_24h}Δ` : 'stable'}</span>
169+ </button>
170+ );
171+ })}
172+ </div>
173+
174+ {err && <p className="mt-6 text-[13px] text-warn">{err}</p>}
175+ {loading && <p className="mt-6 text-[12px] text-ink-3">Loading traceroute…</p>}
176+
177+ {route && !err && (
178+ <div className={loading ? 'opacity-50' : ''}>
179+ <Section label="Comparison" className="mt-6">
180+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
181+ <Stat label="route" value={route.diff.changed ? <span className="text-high">changed</span> : <span className="text-normal">unchanged</span>} sub={`hop Δ ${route.diff.hop_delta >= 0 ? '+' : ''}${route.diff.hop_delta}`} />
182+ <Stat label="latency shift" value={<span style={{ color: route.diff.latency_shift_ms > 5 ? 'var(--p-stressed)' : undefined }}>{`${route.diff.latency_shift_ms >= 0 ? '+' : ''}${fmt(route.diff.latency_shift_ms, 1)} ms`}</span>} />
183+ <Stat label="added hops" value={fmtInt(route.diff.added.length)} />
184+ <Stat label="removed hops" value={fmtInt(route.diff.removed.length)} />
185+ <Stat label="baseline share 7d" value={fmtPct(route.baseline.share_7d)} />
186+ </div>
187+ <div className="mt-4 space-y-2">
188+ <AsnChips label="baseline path" path={route.diff.asn_path_baseline} other={route.diff.asn_path_current} />
189+ <AsnChips label="current path" path={route.diff.asn_path_current} other={route.diff.asn_path_baseline} />
190+ </div>
191+ </Section>
192+
193+ <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-2">
194+ <HopList hops={route.baseline.hops} diffIps={removed} kind="removed" title="Baseline route" hash={route.baseline.route_hash} meta={<>dominant {fmtPct(route.baseline.share_7d)} of 7 d · first seen {format(route.baseline.first_seen, 'date')} · last seen {format(route.baseline.last_seen, 'short')}</>} />
195+ <HopList hops={route.current.hops} diffIps={added} kind="added" title="Current route" hash={route.current.route_hash} total={route.current.total_ms} meta={<>sampled {format(route.current.ts, 'short')} · {route.current.reached ? 'destination reached' : 'destination NOT reached'}</>} />
196+ </div>
197+
198+ <Section label="24 h route fingerprints" right={<span>one cell per traceroute sample · colour = fingerprint</span>} className="mt-6">
199+ <div className="flex h-8 w-full gap-px overflow-hidden rounded-[3px]" role="img" aria-label="Route hash history">
200+ {route.history_24h.map((h, i) => (
201+ <span key={i} className="flex-1 min-w-[2px]" style={{ background: hashColor.get(h.route_hash) ?? '#3A4756' }} title={`${format(h.ts, 'short')} · ${h.route_hash.slice(0, 12)} · ${h.hop_count} hops · ${fmt(h.total_ms, 1)} ms`} />
202+ ))}
203+ </div>
204+ <div className="num mt-1 flex justify-between text-[10.5px] text-ink-3">
205+ <span>{route.history_24h[0] ? <Time ts={route.history_24h[0].ts} /> : ''}</span>
206+ <span>{route.history_24h.at(-1) ? <Time ts={route.history_24h.at(-1)!.ts} /> : ''}</span>
207+ </div>
208+ </Section>
209+
210+ <Section label="7 d route share">
211+ <ul className="space-y-2">
212+ {route.route_share_7d.map((r) => (
213+ <li key={r.route_hash} className="grid grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-3 text-[12px]">
214+ <span className="size-3 rounded-[2px]" style={{ background: hashColor.get(r.route_hash) }} aria-hidden="true" />
215+ <div className="min-w-0">
216+ <div className="flex flex-wrap items-baseline gap-x-3">
217+ <span className="num text-ink">{r.route_hash.slice(0, 12)}</span>
218+ <span className="num text-ink-2">{r.asn_path.map((a) => `AS${a}`).join(' › ')}</span>
219+ {r.route_hash === route.baseline.route_hash && <span className="label">baseline</span>}
220+ {r.route_hash === route.current.route_hash && <span className="label text-high">current</span>}
221+ </div>
222+ <Bar value={r.share * 100} color={hashColor.get(r.route_hash)} className="mt-1" />
223+ </div>
224+ <span className="num text-ink">{fmtPct(r.share)}</span>
225+ </li>
226+ ))}
227+ </ul>
228+ </Section>
229+ </div>
230+ )}
231+ </div>
232+ );
233+}
added apps/web/src/components/service/ProbeTargetMatrix.tsx +56 −0
@@ -0,0 +1,56 @@
1+import { fmt } from '@/lib/format';
2+import type { ServiceDetail, TargetRow } from '@/lib/types';
3+
4+/** z → colour: |z|<1.5 neutral, <3 elevated, <5 high, ≥5 severe; failing cell = severe outline. */
5+function zColor(z: number, ok: boolean): string {
6+ if (!ok) return 'var(--p-severe)';
7+ const a = Math.abs(z);
8+ if (a < 1) return 'var(--panel-2)';
9+ if (a < 1.5) return 'rgba(76,201,240,0.25)';
10+ if (a < 3) return 'rgba(233,196,106,0.45)';
11+ if (a < 5) return 'rgba(231,111,81,0.6)';
12+ return 'rgba(214,40,40,0.75)';
13+}
14+
15+export function ProbeTargetMatrix({ matrix, targets }: { matrix: ServiceDetail['matrix']; targets: TargetRow[] }) {
16+ if (!matrix.length || !targets.length) return <p className="py-3 text-[12.5px] text-ink-3">No matrix available.</p>;
17+ const cols = targets.map((t) => t.target_id);
18+ return (
19+ <div className="scroll-x -mx-3 px-3">
20+ <table className="text-[11px]">
21+ <thead>
22+ <tr>
23+ <th className="label sticky left-0 bg-bg py-1 pr-3 text-left font-medium">probe \ target</th>
24+ {targets.map((t) => (
25+ <th key={t.target_id} className="px-0.5 pb-1 align-bottom font-normal text-ink-2" title={t.name}>
26+ <span className="block h-20 w-6 [writing-mode:vertical-rl] rotate-180 truncate text-[10px]">{t.name.replace(/^.*· /, '')}</span>
27+ </th>
28+ ))}
29+ </tr>
30+ </thead>
31+ <tbody>
32+ {matrix.map((row) => {
33+ const byId = new Map(row.targets.map((c) => [c.target_id, c]));
34+ return (
35+ <tr key={row.probe_id}>
36+ <th className="num sticky left-0 bg-bg py-0.5 pr-3 text-left font-normal text-ink">
37+ {row.probe_id} <span className="text-ink-3">{row.probe_region}</span>
38+ </th>
39+ {cols.map((id) => {
40+ const c = byId.get(id);
41+ return (
42+ <td key={id} className="p-0.5">
43+ <span className="num flex h-6 w-6 items-center justify-center rounded-[2px] text-[9.5px]" style={{ background: c ? zColor(c.z, c.ok) : 'transparent', color: c && (Math.abs(c.z) >= 3 || !c.ok) ? '#fff' : 'var(--ink-2)', outline: c && !c.ok ? '1px solid var(--p-severe)' : undefined }} title={c ? `${row.probe_id} → ${id}\nTTFB ${fmt(c.ttfb_ms, 0)} ms · z ${fmt(c.z)} · ${c.ok ? 'ok' : 'FAILING'}` : 'not measured'}>
44+ {c ? (c.ok ? fmt(c.z, 1) : '×') : '·'}
45+ </span>
46+ </td>
47+ );
48+ })}
49+ </tr>
50+ );
51+ })}
52+ </tbody>
53+ </table>
54+ </div>
55+ );
56+}
modified data/targets/targets.yaml +1 −1
@@ -70,7 +70,7 @@ targets:
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 } 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 } 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 } 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 } 73+ - { id: azure-status, name: "Azure status", host: azure.status.microsoft, 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 } 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 } 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 } 76 - { id: hetzner, name: "Hetzner", host: www.hetzner.com, cat: cloud, provider: hetzner, svc: hetzner, cc: DE, imp: 4, tier: 1, tr: true }
77 77
Menu

Theme