SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%

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 12 days ago (Sep 12, 2026) parent 0a4be38

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 111 sigs: list[Sig] = []
112 112 rate_scale = float(ctx.eng.get("rate_scale", 0.25))
113 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 115 by_target_dns: dict[str, dict[str, bool]] = defaultdict(dict) # target → resolver → any ok (across probes)
116 116
117 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 137 samples=samples, meta=meta), tags))
138 138
139 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 141 if base:
142 142 for metric, sid in (("ttfb", "ttfb_z"), ("tcp", "tcp_z")):
143 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 186 t = ctx.targets.get(target_id)
187 187 if not t:
188 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 192 regions_failing = {ctx.probes[p]["region"] for p, _ in failing if p in ctx.probes}
191 193 corroborated = len(failing) >= 2 and len(regions_failing) >= 2
192 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 635 # raw window medians (available even before baselines exist)
634 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 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 641 def med(v: list[float]) -> float | None:
639 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 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 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 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 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 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 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