'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { Bar, Section, Stat } from '@/components/ui/primitives';
import { fmt, fmtInt, fmtMs, fmtPct } from '@/lib/format';
import { Time, useTime } from '@/lib/time';
import type { Hop, Probe, RoutePair, RouteResponse, Target } from '@/lib/types';
const HASH_COLORS = ['#5B8DEF', '#E9C46A', '#4CC9F0', '#B497F0', '#F4A261', '#7FB77E', '#E76F51', '#F72585'];
function AsnChips({ path, other, label }: { path: number[]; other: number[]; label: string }) {
return (
{label}
{path.map((asn, i) => {
const inOther = other.includes(asn);
return (
AS{asn}
{i < path.length - 1 && › }
);
})}
);
}
function HopList({ hops, diffIps, kind, title, total, hash, meta }: { hops: Hop[]; diffIps: Set; kind: 'added' | 'removed'; title: string; total?: number | null; hash: string; meta?: React.ReactNode }) {
return (
{title}
{hash.slice(0, 12)} · {hops.length} hops{total != null ? ` · ${fmtMs(total, 1)}` : ''}
{meta &&
{meta}
}
{hops.map((h) => {
const flagged = diffIps.has(h.ip);
return (
{h.n}
{h.ip}
{h.asn != null ? (
AS{h.asn} {h.asn_name}
) : (
{h.private ? 'private' : 'unknown'}
)}
{flagged && {kind} }
{h.rtt_ms == null ? '*' : fmtMs(h.rtt_ms, 1)}
);
})}
);
}
export function RouteExplorer({ pairs, probes, targets, initialPair, initialRoute }: { pairs: RoutePair[]; probes: Probe[]; targets: Target[]; initialPair: RoutePair | null; initialRoute: RouteResponse | null }) {
const [probe, setProbe] = useState(initialPair?.probe_id ?? '');
const [target, setTarget] = useState(initialPair?.target_id ?? '');
const [route, setRoute] = useState(initialRoute);
const [err, setErr] = useState<{ key: string; message: string } | null>(null);
const pairKey = `${probe}|${target}`;
const routeMatches = Boolean(route && route.probe.probe_id === probe && route.target.target_id === target);
const errMsg = err && err.key === pairKey ? err.message : null;
const loading = Boolean(probe && target) && !routeMatches && !errMsg;
const { format } = useTime();
const targetName = useMemo(() => new Map(targets.map((t) => [t.target_id, t.name])), [targets]);
const probeName = useMemo(() => new Map(probes.map((p) => [p.probe_id, p.name])), [probes]);
const targetsForProbe = useMemo(() => {
const ids = new Set(pairs.filter((p) => p.probe_id === probe).map((p) => p.target_id));
return targets.filter((t) => ids.has(t.target_id));
}, [pairs, probe, targets]);
const probeIds = useMemo(() => [...new Set(pairs.map((p) => p.probe_id))], [pairs]);
useEffect(() => {
if (!probe || !target || routeMatches) return;
const ctrl = new AbortController();
fetch(`/api/v1/routes?probe=${encodeURIComponent(probe)}&target=${encodeURIComponent(target)}`, { signal: ctrl.signal })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(r.status === 404 ? 'No traceroute sampled for this pair' : `API ${r.status}`))))
.then((d: RouteResponse) => setRoute(d))
.catch((e: Error) => {
if (e.name !== 'AbortError') setErr({ key: `${probe}|${target}`, message: e.message });
});
return () => ctrl.abort();
}, [probe, target, routeMatches]);
const hashColor = useMemo(() => {
const m = new Map();
const all = [...(route?.route_share_7d.map((r) => r.route_hash) ?? []), ...(route?.history_24h.map((h) => h.route_hash) ?? [])];
for (const h of all) if (!m.has(h)) m.set(h, HASH_COLORS[m.size % HASH_COLORS.length]!);
return m;
}, [route]);
const added = new Set(route?.diff.added.map((h) => h.ip) ?? []);
const removed = new Set(route?.diff.removed.map((h) => h.ip) ?? []);
return (
Probe
{
setProbe(e.target.value);
const first = pairs.find((p) => p.probe_id === e.target.value);
if (first && !pairs.some((p) => p.probe_id === e.target.value && p.target_id === target)) setTarget(first.target_id);
}}
className="mt-1 h-9 w-full rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink"
>
{probeIds.map((id) => (
{id} — {probeName.get(id) ?? ''}
))}
Target
setTarget(e.target.value)} className="mt-1 h-9 w-full rounded-[4px] border border-line bg-panel px-2 text-[13px] text-ink">
{targetsForProbe.map((t) => {
const pr = pairs.find((p) => p.probe_id === probe && p.target_id === t.target_id);
return (
{t.name} ({t.hostname}){pr && pr.changed_24h ? ` — ${pr.changed_24h} changes 24h` : ''}
);
})}
{fmtInt(pairs.length)} sampled pairs · {fmtInt(pairs.filter((p) => !p.stable).length)} unstable
{[...pairs]
.sort((a, b) => b.changed_24h - a.changed_24h)
.slice(0, 10)
.map((p) => {
const active = p.probe_id === probe && p.target_id === target;
return (
{
setProbe(p.probe_id);
setTarget(p.target_id);
}}
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'}`}
>
{p.probe_id} → {targetName.get(p.target_id)?.replace(/ · .*/, '') ?? p.target_id}
{p.changed_24h ? `${p.changed_24h}Δ` : 'stable'}
);
})}
{errMsg &&
{errMsg}
}
{loading &&
Loading traceroute…
}
{route && !errMsg && (
changed : unchanged } sub={`hop Δ ${route.diff.hop_delta >= 0 ? '+' : ''}${route.diff.hop_delta}`} />
5 ? 'var(--p-stressed)' : undefined }}>{`${route.diff.latency_shift_ms >= 0 ? '+' : ''}${fmt(route.diff.latency_shift_ms, 1)} ms`}} />
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')}>} />
sampled {format(route.current.ts, 'short')} · {route.current.reached ? 'destination reached' : 'destination NOT reached'}>} />
one cell per traceroute sample · colour = fingerprint} className="mt-6">
{route.history_24h.map((h, i) => (
))}
{route.history_24h[0] ? : ''}
{route.history_24h.at(-1) ? : ''}
{route.route_share_7d.map((r) => (
{r.route_hash.slice(0, 12)}
{r.asn_path.map((a) => `AS${a}`).join(' › ')}
{r.route_hash === route.baseline.route_hash && baseline }
{r.route_hash === route.current.route_hash && current }
{fmtPct(r.share)}
))}
)}
);
}