spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
1'use client';23import Link from 'next/link';4import { useEffect, useMemo, useState } from 'react';5import { Bar, Section, Stat } from '@/components/ui/primitives';6import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';7import { Time, useTime } from '@/lib/time';8import type { Hop, Probe, RoutePair, RouteResponse, Target } from '@/lib/types';910const HASH_COLORS = ['#5B8DEF', '#E9C46A', '#4CC9F0', '#B497F0', '#F4A261', '#7FB77E', '#E76F51', '#F72585'];1112function 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}3031function 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}6667export 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 [err, setErr] = useState<{ key: string; message: string } | null>(null);72 const pairKey = `${probe}|${target}`;73 const routeMatches = Boolean(route && route.probe.probe_id === probe && route.target.target_id === target);74 const errMsg = err && err.key === pairKey ? err.message : null;75 const loading = Boolean(probe && target) && !routeMatches && !errMsg;76 const { format } = useTime();7778 const targetName = useMemo(() => new Map(targets.map((t) => [t.target_id, t.name])), [targets]);79 const probeName = useMemo(() => new Map(probes.map((p) => [p.probe_id, p.name])), [probes]);80 const targetsForProbe = useMemo(() => {81 const ids = new Set(pairs.filter((p) => p.probe_id === probe).map((p) => p.target_id));82 return targets.filter((t) => ids.has(t.target_id));83 }, [pairs, probe, targets]);84 const probeIds = useMemo(() => [...new Set(pairs.map((p) => p.probe_id))], [pairs]);8586 useEffect(() => {87 if (!probe || !target || routeMatches) return;88 const ctrl = new AbortController();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({ key: `${probe}|${target}`, message: e.message });94 });95 return () => ctrl.abort();96 }, [probe, target, routeMatches]);9798 const hashColor = useMemo(() => {99 const m = new Map<string, string>();100 const all = [...(route?.route_share_7d.map((r) => r.route_hash) ?? []), ...(route?.history_24h.map((h) => h.route_hash) ?? [])];101 for (const h of all) if (!m.has(h)) m.set(h, HASH_COLORS[m.size % HASH_COLORS.length]!);102 return m;103 }, [route]);104105 const added = new Set(route?.diff.added.map((h) => h.ip) ?? []);106 const removed = new Set(route?.diff.removed.map((h) => h.ip) ?? []);107108 return (109 <div>110 <div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_auto]">111 <label className="block min-w-0">112 <span className="label">Probe</span>113 <select114 value={probe}115 onChange={(e) => {116 setProbe(e.target.value);117 const first = pairs.find((p) => p.probe_id === e.target.value);118 if (first && !pairs.some((p) => p.probe_id === e.target.value && p.target_id === target)) setTarget(first.target_id);119 }}120 className="mt-1 h-9 w-full rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink"121 >122 {probeIds.map((id) => (123 <option key={id} value={id}>124 {id} — {probeName.get(id) ?? ''}125 </option>126 ))}127 </select>128 </label>129 <label className="block min-w-0">130 <span className="label">Target</span>131 <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">132 {targetsForProbe.map((t) => {133 const pr = pairs.find((p) => p.probe_id === probe && p.target_id === t.target_id);134 return (135 <option key={t.target_id} value={t.target_id}>136 {t.name} ({t.hostname}){pr && pr.changed_24h ? ` — ${pr.changed_24h} changes 24h` : ''}137 </option>138 );139 })}140 </select>141 </label>142 <div className="flex items-end">143 <p className="num text-[11px] text-ink-3">144 {fmtInt(pairs.length)} sampled pairs · {fmtInt(pairs.filter((p) => !p.stable).length)} unstable145 </p>146 </div>147 </div>148149 <div className="mt-4 flex flex-wrap gap-1.5" aria-label="Changed pairs">150 {[...pairs]151 .sort((a, b) => b.changed_24h - a.changed_24h)152 .slice(0, 10)153 .map((p) => {154 const active = p.probe_id === probe && p.target_id === target;155 return (156 <button157 key={`${p.probe_id}-${p.target_id}`}158 type="button"159 onClick={() => {160 setProbe(p.probe_id);161 setTarget(p.target_id);162 }}163 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'}`}164 >165 {p.probe_id} → {targetName.get(p.target_id)?.replace(/ · .*/, '') ?? p.target_id}166 <span className={p.changed_24h ? 'ml-1.5 text-warn' : 'ml-1.5 text-ink-3'}>{p.changed_24h ? `${p.changed_24h}Δ` : 'stable'}</span>167 </button>168 );169 })}170 </div>171172 {errMsg && <p className="mt-6 text-[13px] text-warn">{errMsg}</p>}173 {loading && <p className="mt-6 text-[12px] text-ink-3">Loading traceroute…</p>}174175 {route && !errMsg && (176 <div className={loading ? 'opacity-50' : ''}>177 <Section label="Comparison" className="mt-6">178 <div className="grid grid-cols-2 gap-4 sm:grid-cols-5">179 <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}`} />180 <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>} />181 <Stat label="added hops" value={fmtInt(route.diff.added.length)} />182 <Stat label="removed hops" value={fmtInt(route.diff.removed.length)} />183 <Stat label="baseline share 7d" value={fmtPct(route.baseline.share_7d)} />184 </div>185 <div className="mt-4 space-y-2">186 <AsnChips label="baseline path" path={route.diff.asn_path_baseline} other={route.diff.asn_path_current} />187 <AsnChips label="current path" path={route.diff.asn_path_current} other={route.diff.asn_path_baseline} />188 </div>189 </Section>190191 <div className="grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-2">192 <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')}</>} />193 <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'}</>} />194 </div>195196 <Section label="24 h route fingerprints" right={<span>one cell per traceroute sample · colour = fingerprint</span>} className="mt-6">197 <div className="flex h-8 w-full gap-px overflow-hidden rounded-[3px]" role="img" aria-label="Route hash history">198 {route.history_24h.map((h, i) => (199 <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`} />200 ))}201 </div>202 <div className="num mt-1 flex justify-between text-[10.5px] text-ink-3">203 <span>{route.history_24h[0] ? <Time ts={route.history_24h[0].ts} /> : ''}</span>204 <span>{route.history_24h.at(-1) ? <Time ts={route.history_24h.at(-1)!.ts} /> : ''}</span>205 </div>206 </Section>207208 <Section label="7 d route share">209 <ul className="space-y-2">210 {route.route_share_7d.map((r) => (211 <li key={r.route_hash} className="grid grid-cols-[14px_minmax(0,1fr)_auto] items-center gap-3 text-[12px]">212 <span className="size-3 rounded-[2px]" style={{ background: hashColor.get(r.route_hash) }} aria-hidden="true" />213 <div className="min-w-0">214 <div className="flex flex-wrap items-baseline gap-x-3">215 <span className="num text-ink">{r.route_hash.slice(0, 12)}</span>216 <span className="num text-ink-2">{r.asn_path.map((a) => `AS${a}`).join(' › ')}</span>217 {r.route_hash === route.baseline.route_hash && <span className="label">baseline</span>}218 {r.route_hash === route.current.route_hash && <span className="label text-high">current</span>}219 </div>220 <Bar value={r.share * 100} color={hashColor.get(r.route_hash)} className="mt-1" />221 </div>222 <span className="num text-ink">{fmtPct(r.share)}</span>223 </li>224 ))}225 </ul>226 </Section>227 </div>228 )}229 </div>230 );231}232