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%
12.6 KB · 232 lines tsx
Raw Blame History
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