SPB Git
12.5 KB · 234 lines tsx
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 [loading, setLoading] = useState(false);72  const [err, setErr] = useState<string | null>(null);73  const { format } = useTime();7475  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]);8283  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-deps98  }, [probe, target]);99100  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]);106107  const added = new Set(route?.diff.added.map((h) => h.ip) ?? []);108  const removed = new Set(route?.diff.removed.map((h) => h.ip) ?? []);109110  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          <select116            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)} unstable147          </p>148        </div>149      </div>150151      <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              <button159                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>173174      {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>}176177      {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>192193          <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>197198          <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>209210          <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}234
Files · be6a3c4c9e1838cf98ba8aa0b95cc0ee163b31bc
Menu

Theme