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>
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 | |