Combine current upstream, Atlas observer fixes and Topology for dev1

This commit is contained in:
n30nex committed 2026-10-15 04:33:25 -04:00
commit 3c1ac9d58a
35 files changed
+1618 -40

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.1.0-dev1.4",
"version": "2.1.0-dev1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.1.0-dev1.4",
"version": "2.1.0-dev1.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nazka/map-gl-js-spiderfy": "^2.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.1.0-dev1.4",
"version": "2.1.0-dev1.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+9 -1
View File
@@ -37,6 +37,7 @@ import type { PacketDetail } from "./types/api";
// Map (maplibre-gl, ~1MB) and My Atlas are lazy-loaded so their chunks are fetched on first visit
// instead of bloating the initial bundle.
const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage })));
const TopologyPage = lazy(() => import("./features/topology/TopologyPage").then(m => ({ default: m.TopologyPage })));
const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView })));
// Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats.
@@ -89,13 +90,19 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) {
// wouldn't otherwise know was shareable). All-regions clears both params; any legacy ?region is folded
// in. The guard skips redundant writes (and any setSearchParams feedback loop); replace keeps it out of
// history.
function RegionUrlSync() {
export function RegionUrlSync() {
const { selection } = useRegionSelection();
const previousSelection = useRef(selection);
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const next = selectionToParams(selection, searchParams);
if (previousSelection.current !== selection) {
next.delete("topoNode");
next.delete("topoFocus");
}
previousSelection.current = selection;
if (next.toString() === searchParams.toString()) return;
setSearchParams(next, { replace: true, state: location.state });
}, [selection, searchParams, setSearchParams, location.state]);
@@ -304,6 +311,7 @@ function AppInner() {
const tabContent: Record<string, React.ReactNode> = {
MyAtlas: <MyAtlasPage onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} />,
Topology: <TopologyPage wsManager={wsManager} onViewNode={viewNode} onViewObserver={viewObserver} onAnalyzePacket={viewPacket} />,
Packets: (
<PacketList
wsManager={wsManager}
+20
View File
@@ -472,3 +472,23 @@ export function isNotFound(err: unknown): boolean {
}
export { ApiError };
// Public cached MeshMapper metadata; counts are regional, never per-link evidence.
export interface ScopeCatalogue {
iata: string;
url: string;
generatedAt: number;
checkedAt: number;
freshUntil: number;
lastError?: string;
repeaters: number;
scoped: number;
scopes: { name: string; repeaters: number; default: number; monitored: boolean; wardriving: boolean }[];
}
export function getScopeCatalogues(): Promise<ScopeCatalogue[]> {
return request("/scope-catalogues");
}
export function getTopologyLinks(iatas: string[] | undefined, window: string, signal?: AbortSignal): Promise<{ links: [string,string][]; capped: boolean; since: number; until: number }> {
return request("/routes/topology", { iatas: iatas?.join(","), window }, signal);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { touchClick } from "../lib/touch-click";
// Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet.
const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const;
const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics", "MyAtlas"] as const;
const OVERFLOW_TABS = ["Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas"] as const;
// inline SVGs, 20px / 1.6 stroke to match the rest of the icons
function Icon({ name }: { name: string }) {
const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
+1
View File
@@ -26,6 +26,7 @@ export function channelDisplayName(ch: ChannelSummary): string {
}
export interface ChannelMessage {
isPublic?: boolean; // Confirmed default Public decryption key, never inferred from the hash.
id: number;
packetHash: string;
channelHash: string;
+8 -2
View File
@@ -1,5 +1,7 @@
import type { Feature, FeatureCollection, LineString, Point } from "geojson";
import type { PacketDetail, Observation, ResolvedHop } from "../../types/api";
import { isQuestionable } from "../traces/trace-quality";
import { PayloadType } from "../../types/enums";
import { packetChain, locatedHopNode } from "./packet-flow";
@@ -73,8 +75,12 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] {
add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain));
}
}
if (isTrace && detail.resolvedRoute) {
add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute));
if (isTrace && detail.resolvedRoute && !isQuestionable(detail.traceQuality)) {
// Payload hashes describe the requested route; only consumed SNR hops are observed.
const payload = typeof detail.parsedPayload === "object" ? detail.parsedPayload : undefined;
const consumed = Array.isArray(payload?.snrValues) ? payload.snrValues.length : 0;
add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute.slice(0, consumed)));
}
// fastest first; missing propagation (incl. the trace route) sorts last
+1 -1
View File
@@ -79,7 +79,7 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
)}
</div>
{obs.pathBytes && (
{obs.pathBytes && obs.traceQuality?.status !== "suspect" && (
<div className="flex items-center gap-1 mt-2 font-mono text-[11px] pt-1.5 border-t border-border-subtle">
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">{t("observation.path")}</span>
<PathData pathBytes={obs.pathBytes} hashSize={obs.pathLength.hashSize} resolvedPath={obs.resolvedPath} size="sm" onViewNode={onViewNode} snrValues={isTrace ? snrValues ?? [] : undefined} />
+11 -4
View File
@@ -13,6 +13,7 @@ import { formatHex, formatPropagation } from "../../lib/formatters";
import { Timestamp } from "../../components/Timestamp";
import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, transportCodeHex } from "./packet-structure";
import { PayloadBreakdown } from "./payload-renderers";
import { TraceQualityNotice } from "../traces/TraceQualityNotice";
import { PathData } from "./PathData";
import { buildPacketPaths } from "../map/packet-path";
import { PacketInvestigation } from "./PacketInvestigation";
@@ -197,7 +198,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
)}
{/* Path length */}
{selectedObs && (
{selectedObs && detail.header.payloadType !== PayloadType.TRACE && (
<ColorAccentField field="pathLength">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.pathLength")}</div>
<div className="flex gap-x-4">
@@ -208,9 +209,13 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
</ColorAccentField>
)}
{/* Path data — TRACE's pathBytes are now its trace path hashes (matching hashSize/hopCount
and resolvedPath), so it resolves through PathData like every other type. */}
{selectedObs?.pathBytes && (
{selectedObs?.wirePathBytes != null && (
<ColorAccentField field="pathData">
<div className="text-text-dim text-xs mb-1">{t("traces.quality.snrBytes")}</div>
<code className="break-all">{selectedObs.wirePathBytes.toUpperCase() || "—"}</code>
</ColorAccentField>
)}
{selectedObs?.pathBytes && detail.header.payloadType !== PayloadType.TRACE && (
<ColorAccentField field="pathData">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{t("packetAnalyzer.pathData")}</div>
<PathData pathBytes={selectedObs.pathBytes} hashSize={selectedObs.pathLength.hashSize} resolvedPath={selectedObs.resolvedPath} onViewNode={onViewNode} />
@@ -226,6 +231,8 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
</div>
</DrawerSection>
<TraceQualityNotice quality={detail.traceQuality} />
{detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
<DrawerSection title={t("packetAnalyzer.payloadBreakdown")}>
<div className="font-mono text-[13px]">
+1 -1
View File
@@ -37,7 +37,7 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv
<summary className="flex cursor-pointer list-none items-center gap-2 rounded px-1 py-1.5 font-mono text-xs hover:bg-text-normal/3 [&::-webkit-details-marker]:hidden">
<span aria-hidden className="text-[9px] text-text-dim transition-transform group-open/path:rotate-90">▶</span>
<span className="shrink-0 text-text-normal">{t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })}</span>
{group.hashes.length > 0 && <code className="min-w-0 truncate text-primary">{group.hashes.join(" → ")}</code>}
{group.hashes.length > 0 && detail.traceQuality?.status !== "suspect" && <code className="min-w-0 truncate text-primary">{group.hashes.join(" → ")}</code>}
<span className="ml-auto shrink-0 rounded-sm bg-text-normal/5 px-1.5 text-[11px] text-text-muted" aria-label={t("investigation.observations", { count: group.reports.length })}>×{group.reports.length}</span>
</summary>
<ul className="mt-1 mb-2 flex flex-col gap-1.5">{group.reports.map(report => {
+3 -3
View File
@@ -35,11 +35,11 @@ export function buildObservationFrame(detail: PacketDetail, obs: Observation | n
// rather than dropping a byte and shifting every field after it out of alignment.
const pad = (b?: string) => (!b ? "00" : b.length === 1 ? "0" + b : b);
const tc = detail.transportCodes;
const header = pad(detail.header.raw) + (tc ? transportCodeHex(tc.regionCode) + transportCodeHex(tc.subRegionCode) : "");
const header = pad(detail.header.raw.slice(0, 2)) + (tc ? transportCodeHex(tc.regionCode) + transportCodeHex(tc.subRegionCode) : "");
const payload = detail.rawPayload ?? "";
if (!obs) return header + payload;
const pathLen = pad(obs.pathLength.raw);
const path = obs.pathBytes ?? "";
const path = obs.wirePathBytes ?? obs.pathBytes ?? "";
return header + pathLen + path + payload;
}
@@ -63,7 +63,7 @@ export function computeFieldRanges(
ranges.pathLength = { start: offset, end: offset + 1 };
offset += 1;
const pathByteCount = obs.pathLength.hashSize * obs.pathLength.hopCount;
const pathByteCount = obs.wirePathBytes != null ? obs.wirePathBytes.length / 2 : obs.pathLength.hashSize * obs.pathLength.hopCount;
if (pathByteCount > 0) {
ranges.pathData = { start: offset, end: offset + pathByteCount };
}
+59
View File
@@ -0,0 +1,59 @@
import type { LiveChatter } from "./chatter";
import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { ChartColors } from "../stats/chartTheme";
import type { PathDisplay, LiveTraffic, Topology } from "./topology";
import { createRenderer, type CameraView } from "./topology-renderer";
export function TopologyCanvas({ graph, traffic, chatter, colors, selected, onSelect, region, onRegion, heading, controls, settings, motion, active, tick }: { graph: Topology; traffic: LiveTraffic; chatter?: LiveChatter; colors: ChartColors; selected: string; onSelect: (id: string) => void; region: string; onRegion: (code: string) => void; heading: ReactNode; controls: ReactNode; settings: ReactNode; motion: boolean; active: boolean; tick: number }) {
const { t } = useTranslation();
const canvas = useRef<HTMLCanvasElement>(null);
const panel = useRef<HTMLDivElement>(null);
const displayMenu = useRef<HTMLDetailsElement>(null);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const change = () => setExpanded(document.fullscreenElement === panel.current);
document.addEventListener("fullscreenchange", change);
return () => document.removeEventListener("fullscreenchange", change);
}, []);
const selectNode = useEffectEvent(onSelect);
const selectRegion = useEffectEvent(onRegion);
const [display, setDisplay] = useState<PathDisplay>("all");
useEffect(() => {
const close = (event: PointerEvent) => { if (displayMenu.current && !displayMenu.current.contains(event.target as Node)) displayMenu.current.open = false; };
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
const renderer = useRef<ReturnType<typeof createRenderer>>(null);
const cameraView = useRef<CameraView | undefined>(undefined);
useEffect(() => {
if (!canvas.current) return;
renderer.current = createRenderer(canvas.current, graph, traffic, colors, id => selectNode(id), code => selectRegion(code), cameraView.current, chatter);
return () => { if (graph.nodes.length) cameraView.current = renderer.current?.view(); renderer.current?.dispose(); renderer.current = null; };
}, [graph, traffic, colors, chatter]);
useEffect(() => { renderer.current?.update(selected, region, selected || display !== "selected" ? display : "all", motion, active); }, [selected, region, display, motion, active, tick, graph, colors]);
const button = "min-h-11 rounded border border-border bg-bg-surface px-3 text-xs text-text-bright hover:border-primary";
const compactButton = "min-h-8 rounded border border-border bg-bg-surface px-2 text-[11px] text-text-bright hover:border-primary";
return <div ref={panel} className={`flex min-h-0 min-w-0 flex-col gap-2 bg-bg-base ${expanded ? "h-dvh p-2" : "h-full"}`}>
<header className="relative z-10 flex flex-wrap items-center gap-x-3 gap-y-2 px-1">
{heading}
<div className="ml-auto flex flex-wrap items-center gap-1.5">
{controls}
<details ref={displayMenu} className="sm:relative" onKeyDown={e => { if (e.key === "Escape" && e.currentTarget.open) { e.preventDefault(); e.currentTarget.open = false; e.currentTarget.querySelector("summary")?.focus(); } }}>
<summary className={`${compactButton} cursor-pointer content-center`}>{t("topology.display")}</summary>
<div className="absolute left-2 right-2 top-full z-20 mt-2 max-h-[min(60dvh,440px)] space-y-3 overflow-y-auto rounded-sm border border-border bg-bg-surface p-3 shadow-xl sm:left-auto sm:right-0 sm:w-72">
<label className="block space-y-1 text-xs text-text-normal"><span>{t("topology.linkView")}</span><select className={`${button} block w-full`} value={selected || display !== "selected" ? display : "all"} onChange={e => setDisplay(e.target.value as PathDisplay)}><option value="all">{t("topology.allPaths")}</option><option value="bundled">{t("topology.bundledPaths")}</option><option value="selected" disabled={!selected}>{t("topology.selectedPaths")}</option></select></label>
<div className="flex gap-2"><button className={button} onClick={() => renderer.current?.angle(true)}>{t("topology.topView")}</button><button className={button} onClick={() => renderer.current?.angle(false)}>{t("topology.threeDView")}</button></div>
{settings}
<p className="text-[11px] leading-relaxed text-text-muted">{t("topology.controlsPan")}</p>
</div>
</details>
{document.fullscreenEnabled && <button className={compactButton} onClick={() => { const action = expanded ? document.exitFullscreen() : panel.current?.requestFullscreen(); action?.catch(() => {}); }}>{t(expanded ? "topology.collapse" : "topology.expand")}</button>}
</div>
</header>
<div className="relative min-h-0 flex-1 overflow-hidden rounded-sm border border-border">
<canvas ref={canvas} tabIndex={0} aria-label={t("topology.canvas")} className="absolute inset-0 block h-full w-full touch-none cursor-move focus-visible:outline-offset-[-3px]" />
<div className="absolute bottom-3 right-3 flex gap-1 rounded-sm bg-bg-base/90 p-1"><button className={`${button} min-w-11`} aria-label={t("topology.zoomIn")} onClick={() => renderer.current?.zoom(1.3)}>+</button><button className={`${button} min-w-11`} aria-label={t("topology.zoomOut")} onClick={() => renderer.current?.zoom(1 / 1.3)}>−</button><button className={button} onClick={() => renderer.current?.fit()}>{t("topology.fit")}</button></div>
</div>
</div>;
}
+192
View File
@@ -0,0 +1,192 @@
import { LiveChatter } from "./chatter";
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { getScopeCatalogues } from "../../api/client";
import type { WsManager } from "../../api/ws-manager";
import { useRegion } from "../../hooks/useRegion";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useChartColors, nodeTypeColor } from "../stats/chartTheme";
import { NODE_TYPES } from "../../lib/node-types";
import { buildTopology, LiveTraffic, loadTopology, loadTopologyRoutes, ROUTE_WINDOWS, reportFromEvent, type LiveReport, type PacketKind, flowColor, linkContext } from "./topology";
import { TopologyCanvas } from "./TopologyCanvas";
import { BottomSheet } from "../../components/BottomSheet";
type Props = { wsManager: WsManager; active?: boolean; onViewNode: (id: string) => void; onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void };
const button = "min-h-11 rounded-sm border border-border bg-bg-surface px-3 text-xs text-text-bright hover:border-primary disabled:opacity-40";
export function TopologyPage(props: Props) {
const { iatas, regionKey, isResolved } = useRegion();
return <TopologySession key={regionKey} {...props} iatas={iatas} resolved={isResolved !== false} />;
}
function TopologySession({ wsManager, active = true, onViewNode, onViewObserver, onAnalyzePacket, iatas, resolved }: Props & { iatas: string[] | undefined; resolved: boolean }) {
const { t } = useTranslation();
const colors = useChartColors();
const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [animate, setAnimate] = useState(true);
const [showChatter,setShowChatter] = useState(true);
const [chatter] = useState(()=>new LiveChatter());
const focus = iatas?.length === 1 ? iatas[0]! : "";
const [search, setSearch] = useState("");
const [copied, setCopied] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [activityOpen, setActivityOpen] = useState(false);
const mobile = useMediaQuery("(max-width: 767px)");
const section = useRef<HTMLElement>(null);
const searchInput = useRef<HTMLInputElement>(null);
const [params, setParams] = useSearchParams();
const selected = params.get("topoNode") || "";
const showInspector = active && (searchOpen || !!selected);
const requestedWindow = params.get("topoWindow") || "15m";
const routeWindow = Object.hasOwn(ROUTE_WINDOWS, requestedWindow) ? requestedWindow as keyof typeof ROUTE_WINDOWS : "15m";
const data = useQuery({ queryKey: ["topology", iatas ?? "*"], queryFn: ({ signal }) => loadTopology(iatas, signal), enabled: active && resolved, staleTime: 120_000, refetchOnWindowFocus: false });
const routes = useQuery({ queryKey: ["topology-routes", iatas ?? "*", routeWindow], queryFn: ({ signal }) => loadTopologyRoutes(iatas, signal, ROUTE_WINDOWS[routeWindow]), enabled: active && resolved, staleTime: 60_000, retry: false, refetchOnWindowFocus: false, placeholderData: keepPreviousData });
const catalogues = useQuery({ queryKey: ["scope-catalogues"], queryFn: getScopeCatalogues, enabled: showInspector && resolved, staleTime: 300_000, refetchInterval: 300_000, refetchIntervalInBackground: false, refetchOnWindowFocus: false, retry: false });
const graph = useMemo(() => buildTopology(data.data?.nodes ?? [], iatas, routes.data?.routes ?? []), [data.data, iatas, routes.data]);
const requestedCameraRegion = params.get("topoFocus") || focus;
const cameraRegion = graph.regions.some(r => r.code === requestedCameraRegion) ? requestedCameraRegion : "";
const select = useCallback(async (id: string) => {
if (document.fullscreenElement && section.current?.contains(document.fullscreenElement)) await document.exitFullscreen().catch(() => {});
setParams(prev => { const next = new URLSearchParams(prev); next.set("topoNode", id); const region = graph.byId.get(id)?.region; if (region) next.set("topoFocus", region); return next; });
}, [setParams, graph]);
const focusCamera = (code: string) => setParams(prev => { const next = new URLSearchParams(prev); if (code) next.set("topoFocus", code); else next.delete("topoFocus"); next.delete("topoNode"); return next; });
const [traffic] = useState(() => new LiveTraffic());
const [live, setLive] = useState<{ reports: LiveReport[]; tick: number; now: number; gap: boolean; capped: boolean }>(() => ({ reports: [], tick: 0, now: Date.now(), gap: false, capped: false }));
const subscribe = useCallback((notify: () => void) => wsManager.onStatusChange(notify), [wsManager]);
const status = useSyncExternalStore(subscribe, () => wsManager.getStatus());
const receive = useEffectEvent((event: Parameters<typeof reportFromEvent>[0]) => {
if (document.hidden || (iatas && !iatas.includes(event?.observation?.iata))) return;
if (showChatter) chatter.observe(event,graph,Date.now());
const report = reportFromEvent(event, graph, Date.now());
if (report) traffic.add(report);
});
const receiveChat = useEffectEvent((event: Parameters<LiveChatter["message"]>[0]) => {
if (!document.hidden && showChatter) chatter.message(event,Date.now());
});
useEffect(() => { if (!showChatter) chatter.clear(); },[showChatter,chatter]);
useEffect(() => {
if (!active || !resolved || !data.data) return;
const packet = wsManager.onPacketObservation(event => receive(event));
const messages = wsManager.onChannelMessage(event=>receiveChat(event));
const lagged = wsManager.onLagged(() => {traffic.markGap();chatter.clear();});
const visibility = () => { wsManager.setResolvePath(!document.hidden); if (document.hidden) { traffic.markGap(); traffic.clearFlows(); chatter.clear(); } };
visibility();
document.addEventListener("visibilitychange", visibility);
const timer = window.setInterval(() => {
if (document.hidden) return;
const now = Date.now(); traffic.prune(now);
setLive(previous => ({ reports: [...traffic.reports], tick: previous.tick + 1, now, gap: traffic.gapUntil > now, capped: traffic.cappedUntil > now }));
}, 500);
return () => { packet(); messages(); lagged(); chatter.clear(); clearInterval(timer); document.removeEventListener("visibilitychange", visibility); wsManager.setResolvePath(false); traffic.clearFlows(); traffic.markGap(); };
}, [active, resolved, data.data, iatas, traffic, wsManager, chatter]);
const counts = useMemo(() => {
const observers = new Map<string, { name: string; count: number; iata: string }>();
const kinds: Record<PacketKind, number> = { advert: 0, text: 0, trace: 0, ack: 0, other: 0 };
for (const report of live.reports) { const old = observers.get(report.observerId); observers.set(report.observerId, { name: report.observerName, count: (old?.count ?? 0) + 1, iata: report.iata }); kinds[report.kind]++; }
return { observers: [...observers].sort((a, b) => b[1].count - a[1].count), kinds, paths: live.reports.filter(r => r.segments.length).length };
}, [live.reports]);
const matches = useMemo(() => search.trim() ? graph.nodes.filter(n => `${n.name ?? ""} ${n.publicKey}`.toLowerCase().includes(search.trim().toLowerCase())).slice(0, 30) : [], [graph.nodes, search]);
const node = graph.byId.get(selected);
const catalogue = catalogues.data?.find(c => c.iata === (node?.region || focus));
const catalogueStale = catalogue && (!!catalogue.lastError || !catalogue.checkedAt || live.now > catalogue.freshUntil);
const visibleNeighbours = node ? graph.links.filter(([a, b]) => a === node.id || b === node.id).map(([a, b]) => graph.byId.get(a === node.id ? b : a)!) : [];
const neighbours = visibleNeighbours.slice(0, 12);
const share = async () => { try { await navigator.clipboard.writeText(window.location.href); setCopied(true); } catch { setCopied(false); } };
const closeInspector = () => {
setSearchOpen(false); setSearch("");
setParams(prev => { const next = new URLSearchParams(prev); next.delete("topoNode"); return next; });
section.current?.querySelector("canvas")?.focus();
};
const openInspector = async () => {
if (document.fullscreenElement && section.current?.contains(document.fullscreenElement)) await document.exitFullscreen().catch(() => {});
setSearchOpen(true);
};
useEffect(() => { if (searchOpen && active) searchInput.current?.focus(); }, [searchOpen, active]);
const inspector = <div className="space-y-3 overflow-y-auto p-2">
<div className="rounded-sm border border-border bg-bg-surface p-3">
<div className="mb-3 flex items-center justify-between gap-2"><h2 className="text-sm font-semibold text-text-bright">{t("topology.inspector")}</h2><button className={button} onClick={closeInspector}>{t("topology.close")}</button></div>
<input ref={searchInput} className="min-h-11 w-full rounded border border-border bg-bg-base px-3 text-sm text-text-bright" aria-label={t("topology.search")} placeholder={t("topology.search")} value={search} onChange={e => setSearch(e.target.value)} />
<div className="mt-2 max-h-44 overflow-y-auto">{matches.map(n => <button key={n.id} className={`flex min-h-9 w-full items-center justify-between gap-2 rounded px-2 text-left text-xs ${selected === n.id ? "bg-primary/15 text-primary" : "text-text-normal hover:bg-bg-raised"}`} onClick={() => select(n.id)} aria-pressed={selected === n.id}><span className="truncate">{n.name || n.publicKey.slice(0, 10)}</span><span className="shrink-0 font-mono text-[10px]">{n.region}</span></button>)}{search.trim() && !matches.length && <p className="py-3 text-xs text-text-muted">{t("topology.noNodes")}</p>}</div>
{node && <div className="mt-3 space-y-2 border-t border-border pt-3"><h3 className="break-words text-sm text-text-bright">{node.name || node.publicKey.slice(0, 12)}</h3><p className="text-xs text-text-normal">{t("topology.neighbours", { count: visibleNeighbours.length })}</p><div className="flex flex-wrap gap-2"><button className={button} onClick={() => onViewNode(node.id)}>{t("topology.nodeDetails")}</button>{node.observerId && <button className={button} onClick={() => onViewObserver(node.observerId!)}>{t("topology.observerDetails")}</button>}<button className={button} onClick={() => setParams(prev => { const next = new URLSearchParams(prev); next.delete("topoNode"); return next; })}>{t("topology.clear")}</button></div>
{node.defaultScope && <p className="break-words text-xs text-green">{t("topology.defaultScope")}: {node.defaultScope}</p>}
{visibleNeighbours.length > 12 && <p className="text-[10px] text-text-muted">{t("topology.neighbourLimit", { count: visibleNeighbours.length })}</p>}
<div className="max-h-40 overflow-y-auto">{neighbours.map(neighbour => { const context = linkContext(node, neighbour); const listed = context.sharedDefault && catalogues.data?.some(c => (c.iata === node.region || c.iata === neighbour.region) && c.scopes.some(scope => scope.name === context.sharedDefault)); return <button key={neighbour.id} className="flex min-h-10 w-full items-center justify-between gap-2 border-t border-border-subtle text-left text-xs text-text-normal" onClick={() => select(neighbour.id)}><span className="truncate">{neighbour.name || neighbour.publicKey.slice(0, 8)}</span><span className={`shrink-0 font-mono text-[10px] ${context.sharedDefault ? "text-green" : "text-primary"}`} title={listed ? t("topology.cataloguedLink") : undefined}>{context.sharedDefault || `${node.region} ↔ ${neighbour.region}`}{listed ? " · MM" : ""}</span></button>; })}</div>
</div>}
</div>
{(focus || node) && <div className="rounded-sm border border-border bg-bg-surface p-3"><h2 className="text-sm font-semibold text-text-bright">MeshMapper · {node?.region || focus}</h2>
{catalogue ? <><p className={`my-2 text-xs ${catalogueStale ? "text-warn" : "text-text-normal"}`}>{t(catalogueStale ? "topology.catalogueStale" : "topology.catalogueFresh")}</p>{catalogue.generatedAt > 0 && <p className="text-[11px] text-text-normal">{t("topology.catalogueCounts", { count: catalogue.scoped, total: catalogue.repeaters })}</p>}<div className="mt-2 max-h-32 overflow-y-auto">{catalogue.scopes.map(scope => <div key={scope.name} className="flex justify-between gap-2 py-1 text-xs"><span className={scope.name === node?.defaultScope ? "text-green" : "text-text-normal"}>{scope.name}{scope.monitored ? " ◉" : ""}</span><span className="font-mono text-text-muted">{scope.repeaters}</span></div>)}</div><p className="mt-2 text-[10px] text-text-muted">{t("topology.catalogueChecked")}: {catalogue.checkedAt ? new Date(catalogue.checkedAt).toLocaleString() : "—"}</p></> : <p className="mt-2 text-xs text-text-muted">{t(catalogues.isLoading ? "topology.loading" : catalogues.isError ? "topology.catalogueError" : "topology.noCatalogue")}</p>}
<p className="mt-2 text-[10px] text-text-muted">{t("topology.catalogueMeaning")}</p>
</div>}
</div>;
const compactButton = "min-h-8 rounded border border-border bg-bg-surface px-2 text-[11px] text-text-bright hover:border-primary";
const controls = <>
<select className={`${compactButton} max-w-32`} aria-label={t("topology.routeWindow")} value={routeWindow} onChange={e => setParams(prev => { const next = new URLSearchParams(prev); next.set("topoWindow", e.target.value); return next; })}>
{Object.keys(ROUTE_WINDOWS).map(value => <option key={value} value={value}>{t(`topology.windows.${value}`)}</option>)}
</select>
<button className={compactButton} aria-expanded={showInspector} onClick={showInspector ? closeInspector : openInspector}>{t(showInspector ? "topology.hideInspector" : "topology.find")}</button>
</>;
const settings = <>
<label className="flex min-h-9 items-center gap-2 text-xs text-text-normal"><input type="checkbox" checked={showChatter} onChange={e=>setShowChatter(e.target.checked)} />{t("topology.publicChatter")}</label>
<p className="text-[10px] text-text-muted">{t("topology.chatterHint")}</p>
<label className="flex min-h-11 items-center gap-2 text-xs text-text-normal"><input type="checkbox" checked={animate && !reducedMotion} disabled={reducedMotion} onChange={e => setAnimate(e.target.checked)} />{t(reducedMotion ? "topology.reducedMotion" : "topology.motion")}</label>
<div className="flex flex-wrap gap-3 border-t border-border pt-3 text-[11px] text-text-normal">
{NODE_TYPES.map(type => <span key={type.name} className="flex items-center gap-1.5"><span className="h-2 w-2 rounded-full" style={{ background: nodeTypeColor(type.name, colors) }} />{t(`topology.types.${type.name}`)}</span>)}
<span>{t("topology.observerRing")}</span><span style={{ color: colors.secondary }}>━ {t("topology.routeSegments")}</span><span className="text-primary">┄ {t("topology.crossRegion")}</span><span className="text-green">━ {t("topology.sharedDefault")}</span>
</div>
<div className="flex flex-wrap gap-2 border-t border-border pt-3">
<button className={button} onClick={share}>{t(copied ? "topology.copied" : "topology.copy")}</button>
<button className={button} disabled={data.isFetching || routes.isFetching || !active || !resolved} onClick={() => { data.refetch(); routes.refetch(); }}>{t("topology.refresh")}</button>
</div>
</>;
const heading = <div className="flex flex-wrap items-center gap-x-4 gap-y-1">
<div className="flex items-center gap-2"><h1 className="text-base font-semibold text-text-bright">{t("topology.title")}</h1>
<span className={`flex items-center gap-1.5 text-xs ${status !== "connected" ? "text-warn" : "text-green"}`} role="status"><span className="h-2 w-2 rounded-full bg-current beacon-live-dot" />{t(status === "connected" ? "topology.live" : "topology.reconnecting")}</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-text-normal">
<span><b className="font-mono text-text-bright">{data.data ? graph.nodes.length.toLocaleString() : "—"}</b> {t("topology.nodes")}</span>
<span aria-busy={routes.isFetching}><b className="font-mono text-text-bright">{data.data ? graph.links.length.toLocaleString() : "—"}</b> {t("topology.links")}</span>
{routes.isFetching && <span role="status" className="text-primary">{t("topology.loadingRoutes")}</span>}
</div>
</div>;
return <section ref={section} className="h-full min-h-0 w-full min-w-0 overflow-hidden p-2 md:p-3" aria-label={t("topology.title")}>
<div className="mx-auto flex h-full min-h-0 max-w-[2000px] flex-col gap-2">
{data.isError && <p role="alert" className="text-sm text-danger">{t("topology.loadError")} <button className={button} onClick={() => data.refetch()}>{t("topology.refresh")}</button></p>}
{data.isLoading && <p role="status" className="text-sm text-text-normal">{t("topology.loading")}</p>}
{routes.isError && <p role="alert" className="text-xs text-warn">{t("topology.routesError")} <button className={button} onClick={() => routes.refetch()}>{t("topology.refresh")}</button></p>}
{(data.data?.capped || routes.data?.capped || graph.linksCapped || live.capped) && <p className="text-xs text-warn">{t("topology.bounded")}</p>}
<div className={`grid min-h-0 flex-1 min-w-0 items-stretch gap-3 ${showInspector && !mobile ? "md:grid-cols-[minmax(0,1fr)_300px]" : ""}`}>
<TopologyCanvas graph={graph} traffic={traffic} chatter={chatter} colors={colors} selected={selected} onSelect={select} region={cameraRegion} onRegion={focusCamera} heading={heading} controls={controls} settings={settings}
motion={animate && !reducedMotion} active={active} tick={live.tick} />
{showInspector && !mobile && <aside aria-label={t("topology.inspector")} className="min-h-0 min-w-0 overflow-y-auto rounded-sm border border-border bg-bg-surface">{inspector}</aside>}
</div>
{showInspector && mobile && <BottomSheet onClose={closeInspector} label={t("topology.inspector")}>{inspector}</BottomSheet>}
<details className="max-h-[35%] shrink-0 overflow-y-auto rounded-sm border border-border bg-bg-surface" onToggle={e => setActivityOpen(e.currentTarget.open)}>
<summary className={`${activityOpen ? "min-h-9 text-xs" : "min-h-7 text-[11px]"} cursor-pointer content-center px-2 text-text-normal`}>
<span className="font-semibold text-text-bright">{t("topology.activity")}</span>
<span className="ml-2">{t("topology.activityCounts", { count: live.reports.length, observers: counts.observers.length })}</span>
{live.gap && <span className="ml-2 text-warn" title={t("topology.gap")}>{t("topology.incomplete")}</span>}
</summary>
{activityOpen && <div className="space-y-3 p-3 pt-0">
{live.gap && <p className="text-xs text-warn">{t("topology.gap")}</p>}
<div className="rounded-sm border border-border bg-bg-surface p-3">
<div className="flex justify-between text-xs text-text-normal"><span>{t("topology.mix")}</span><span>{t("topology.paths", { count: counts.paths })}</span></div>
<div className="my-3 flex h-2 overflow-hidden rounded bg-bg-raised">{Object.entries(counts.kinds).map(([kind, count]) => <div key={kind} style={{ width: `${100 * count / Math.max(1, live.reports.length)}%`, background: flowColor(kind as PacketKind, colors) }} />)}</div>
<div className="flex flex-wrap gap-4 text-[11px] text-text-normal">{Object.entries(counts.kinds).map(([kind, count]) => <span key={kind}><span style={{ color: flowColor(kind as PacketKind, colors) }}>●</span> {t(`topology.kinds.${kind}`)} <b className="text-text-bright">{count}</b></span>)}</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-sm border border-border bg-bg-surface p-3"><h2 className="mb-2 text-sm font-semibold text-text-bright">{t("topology.reporting")}</h2><div className="max-h-48 space-y-2 overflow-y-auto">{counts.observers.slice(0, 20).map(([id, observer]) => <button key={id} className="relative flex min-h-10 w-full items-center justify-between gap-2 overflow-hidden rounded bg-bg-base px-2 text-left text-xs text-text-bright" onClick={() => onViewObserver(id)}><span className="absolute inset-y-0 left-0 bg-primary/15" style={{ width: `${100 * observer.count / (counts.observers[0]?.[1].count || 1)}%` }} /><span className="relative truncate">{observer.name}</span><span className="relative shrink-0 font-mono text-primary">{observer.count}</span></button>)}{!counts.observers.length && <p className="py-2 text-xs text-text-muted">{t("topology.listening")}</p>}</div></div>
<div className="rounded-sm border border-border bg-bg-surface p-3"><h2 className="mb-2 text-sm font-semibold text-text-bright">{t("topology.latest")}</h2><div className="max-h-64 divide-y divide-border-subtle overflow-y-auto">{live.reports.slice(-10).reverse().map(report => <button key={report.key} className="flex min-h-12 w-full items-center gap-2 py-2 text-left text-xs text-text-normal" onClick={() => onAnalyzePacket(report.hash)}><span style={{ color: flowColor(report.kind, colors) }}>●</span><span className="min-w-0 flex-1"><span className="block font-mono text-text-bright">{report.hash.slice(0, 8)} <span className="text-primary">{report.iata}</span> <span className="text-green">{report.scope}</span></span><span className="block truncate">{report.observerName}</span></span><span className="max-w-24 text-right text-[10px]">{t(report.segments.length ? report.partial ? "topology.partialPath" : "topology.path" : "topology.noPath")}</span></button>)}</div></div>
</div>
</div>}
</details>
</div>
</section>;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ChannelMessage } from "../channels/types";
import type { WsPacketObservation } from "../../types/ws";
import type { Topology } from "./topology";
export const CHAT_MS = 8_000;
export type ChatBubble = { hash: string; sender: string; content: string; nodeId: string; at: number; heardAt: number };
type Pending = { at: number; message?: ChannelMessage; nodeId?: string; heardAt?: number };
// eslint-disable-next-line no-control-regex -- strip control bytes from untrusted display text
const clean = (value: string, limit: number) => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, limit);
// Join the two existing live events in either arrival order. Never fetch a packet
// per message, infer an identity from its sender text, or replay channel history.
export class LiveChatter {
private pending = new Map<string, Pending>();
private entry(hash: string, now: number) {
let entry = this.pending.get(hash);
if (!entry) {
entry = { at: now }; this.pending.set(hash, entry);
if (this.pending.size > 128) this.pending.delete(this.pending.keys().next().value!);
}
return entry;
}
message(message: ChannelMessage, now: number) {
// A server-confirmed decryption key, not the collidable one-byte channel hash/name.
if (message.isPublic !== true || !message.packetHash || typeof message.content !== "string" || !message.content.trim()) return;
this.entry(message.packetHash, now).message = message;
}
observe(event: WsPacketObservation["data"], graph: Topology, now: number) {
if (event?.packet?.payloadType !== 5 || !event.packetHash || !Number.isFinite(event.observation?.heardAt)) return;
const observer = graph.nodes.find(n => n.observerId === event.observation.observerId);
const first = event.observation.resolvedPath?.[0];
const relay = (event.packet.routeType === 0 || event.packet.routeType === 1) && first?.confidence === "high" && first.nodes.length === 1 ? graph.byId.get(first.nodes[0]!.id) : undefined;
const node = observer ?? relay;
if (!node) return;
const entry = this.entry(event.packetHash, now);
if (entry.heardAt == null || event.observation.heardAt < entry.heardAt) {
entry.nodeId = node.id; entry.heardAt = event.observation.heardAt;
}
}
visible(now: number): ChatBubble[] {
const bubbles: ChatBubble[] = [];
for (const [hash, entry] of this.pending) {
if (now - entry.at > 30_000) { this.pending.delete(hash); continue; }
if (!entry.message || !entry.nodeId || now - entry.at < 600 || now - entry.at >= CHAT_MS) continue;
bubbles.push({ hash, sender: clean(entry.message.senderName || "?", 40), content: clean(entry.message.content, 180), nodeId: entry.nodeId, at: entry.at, heardAt: entry.heardAt! });
}
return bubbles;
}
clear() { this.pending.clear(); }
}
+304
View File
@@ -0,0 +1,304 @@
import { CHAT_MS, type LiveChatter } from "./chatter";
import { nodeTypeColor, type ChartColors } from "../stats/chartTheme";
import { DEFAULT_CAMERA, FLOW_MS, project, fitCamera, panCamera, pathControls, visibleLinks, flowColor, linkContext, type Camera, type PathDisplay, type LiveTraffic, type Point3, type Topology } from "./topology";
export type CameraView = { camera: Camera; region: string; extent: number };
export function createRenderer(canvas: HTMLCanvasElement, graph: Topology, traffic: LiveTraffic, colors: ChartColors, select: (id: string) => void, focus: (code: string) => void, view?: CameraView, chatter?: LiveChatter) {
const ctx = canvas.getContext("2d");
if (!ctx) return null;
let camera = { ...(view?.camera ?? DEFAULT_CAMERA), target: { ...(view?.camera.target ?? DEFAULT_CAMERA.target) } };
let transition: { from: Camera; to: Camera; at: number } | null = null;
const background = document.createElement("canvas"), backgroundCtx = background.getContext("2d");
let width = 1, height = 1, frame = 0, previousFrame = 0;
let running = true, motion = true, selected = "", dirty = true, baseDirty = true, regionCode = view?.region ?? "";
let fitted = !!view;
let display: PathDisplay = "all";
let regionHits: { code: string; x: number; y: number; width: number }[] = [];
let lastTrailVersion = -1;
let projected: { id: string; x: number; y: number; depth: number; scale: number }[] = [];
let related = new Set<string>();
const pointers = new Map<number, { x: number; y: number }>();
let moved = false, start = { x: 0, y: 0 }, panning = false;
const regionByCode = new Map(graph.regions.map(r => [r.code, r]));
const extent = Math.max(220, ...graph.regions.map(r => Math.hypot(r.x, r.z) + r.radius));
if (view) camera.zoom *= extent / view.extent;
const p = (point: Point3) => project(point, camera, width, height, extent);
const line = (a: Point3, b: Point3) => { const from = p(a), to = p(b); ctx.moveTo(from.x, from.y); ctx.lineTo(to.x, to.y); };
const curve = (a: Point3, b: Point3, cross: boolean) => pathControls(a, b, cross).map(p);
const strokeCurve = (points: ReturnType<typeof curve>) => {
const [a, c, d, b] = points;
ctx.beginPath(); ctx.moveTo(a!.x, a!.y); ctx.bezierCurveTo(c!.x, c!.y, d!.x, d!.y, b!.x, b!.y); ctx.stroke();
};
const wake = (changed = false) => { baseDirty ||= changed; dirty = true; if (!frame && running && !document.hidden) frame = requestAnimationFrame(draw); };
function fit(smooth = true) {
const regions = regionByCode.has(regionCode) ? [regionByCode.get(regionCode)!] : graph.regions;
const points = regions.flatMap(r => [
{ x: r.x - r.radius - 20, y: 0, z: r.z - r.radius - 30 }, { x: r.x + r.radius + 20, y: 65, z: r.z + r.radius + 30 },
{ x: r.x - r.radius - 20, y: 0, z: r.z + r.radius + 30 }, { x: r.x + r.radius + 20, y: 0, z: r.z - r.radius - 30 },
]);
if (!regionCode) for (const bundle of graph.bundles) {
const a = regionByCode.get(bundle.from)!, b = regionByCode.get(bundle.to)!;
points.push({ x: (a.x + b.x) / 2, y: pathControls(a, b, true)[1].y * 0.75, z: (a.z + b.z) / 2 });
}
const next = fitCamera(points, camera, width, height, extent);
if (smooth && motion) transition = { from: camera, to: next, at: performance.now() };
else { camera = next; transition = null; }
wake(true);
}
function draw(now: number) {
frame = 0;
if (!running || document.hidden) return;
const bubbles = chatter?.visible(Date.now()) ?? [];
const flows = motion ? traffic.flows.filter(r => Date.now() - r.at < FLOW_MS) : [];
// A fixed 30 fps ceiling and capped pixel ratio bound work on phones and large meshes.
if (!dirty && (flows.length || bubbles.length || transition) && now - previousFrame < 32) { frame = requestAnimationFrame(draw); return; }
previousFrame = now;
dirty = false;
if (transition) {
const t = Math.min(1, (now - transition.at) / 420), ease = 1 - (1 - t) ** 3;
const mix = (a: number, b: number) => a + (b - a) * ease, { from, to } = transition;
camera = { yaw: mix(from.yaw, to.yaw), pitch: mix(from.pitch, to.pitch), zoom: mix(from.zoom, to.zoom), target: { x: mix(from.target.x, to.target.x), y: mix(from.target.y, to.target.y), z: mix(from.target.z, to.target.z) } };
baseDirty = true; if (t === 1) transition = null;
}
// Repaint static ink only for a changed camera, graph, style or selection.
if (baseDirty || !backgroundCtx) {
ctx!.clearRect(0, 0, width, height);
ctx!.fillStyle = colors.bgBase;
ctx!.fillRect(0, 0, width, height);
ctx!.lineWidth = 1;
ctx!.strokeStyle = colors.borderSubtle;
ctx!.globalAlpha = 0.65;
ctx!.beginPath();
for (let i = -6; i <= 6; i++) {
const v = extent * i / 6;
line({ x: v, y: -16, z: -extent }, { x: v, y: -16, z: extent });
line({ x: -extent, y: -16, z: v }, { x: extent, y: -16, z: v });
}
ctx!.stroke();
ctx!.globalAlpha = 0.32;
ctx!.strokeStyle = colors.primary;
for (const region of graph.regions) {
ctx!.beginPath();
for (let i = 0; i <= 32; i++) {
const q = p({ x: region.x + Math.cos(i * Math.PI / 16) * region.radius, y: -12, z: region.z + Math.sin(i * Math.PI / 16) * region.radius });
if (i === 0) ctx!.moveTo(q.x, q.y); else ctx!.lineTo(q.x, q.y);
}
ctx!.fillStyle = colors.bgSurface; ctx!.globalAlpha = 0.9; ctx!.fill();
ctx!.globalAlpha = region.code === regionCode ? 0.9 : 0.35; ctx!.stroke();
}
for (const [a, b] of visibleLinks(graph, display, regionCode, selected)) {
const lit = selected === a || selected === b;
const context = linkContext(graph.byId.get(a)!, graph.byId.get(b)!);
ctx!.globalAlpha = selected ? (lit ? 0.7 : 0.07) : 0.2;
ctx!.strokeStyle = lit ? colors.textBright : graph.routeLinks.has([a, b].sort().join("|")) ? colors.secondary : context.sharedDefault ? colors.green : context.crossRegion ? colors.primary : colors.textMuted;
ctx!.setLineDash(context.crossRegion ? [3, 4] : []);
ctx!.lineWidth = lit ? 1.6 : 0.7;
strokeCurve(curve(graph.byId.get(a)!, graph.byId.get(b)!, context.crossRegion));
}
ctx!.setLineDash([]);
for (const trail of traffic.trails.values()) {
const from = graph.byId.get(trail.from), to = graph.byId.get(trail.to); if (!from || !to) continue;
if (display !== "all" && selected !== from.id && selected !== to.id && !(display === "bundled" && regionCode && from.region === regionCode && to.region === regionCode)) continue;
ctx!.globalAlpha = 0.35; ctx!.strokeStyle = flowColor(trail.kind, colors); ctx!.lineWidth = 1;
strokeCurve(curve(from, to, from.region !== to.region));
}
if (display === "bundled") for (const bundle of graph.bundles) {
const points = curve(regionByCode.get(bundle.from)!, regionByCode.get(bundle.to)!, true);
ctx!.globalAlpha = 0.6; ctx!.strokeStyle = colors.primary; ctx!.lineWidth = Math.min(4, 1 + Math.log2(1 + bundle.count) * 0.3); strokeCurve(points);
const x = (points[0]!.x + 3 * points[1]!.x + 3 * points[2]!.x + points[3]!.x) / 8, y = (points[0]!.y + 3 * points[1]!.y + 3 * points[2]!.y + points[3]!.y) / 8;
ctx!.globalAlpha = 1; ctx!.font = "10px monospace"; ctx!.textAlign = "center";
const w = ctx!.measureText(String(bundle.count)).width + 10;
ctx!.fillStyle = colors.bgRaised; ctx!.fillRect(x - w / 2, y - 8, w, 16); ctx!.fillStyle = colors.primary; ctx!.fillText(String(bundle.count), x, y + 3);
}
projected = graph.nodes.map(n => ({ id: n.id, ...p(n) })).filter(q => q.visible).sort((a, b) => b.depth - a.depth);
for (const q of projected) {
if (q.x < -10 || q.x > width + 10 || q.y < -10 || q.y > height + 10) continue;
const n = graph.byId.get(q.id)!;
const lit = related.has(n.id);
const radius = n.id === selected ? 6 : Math.max(0.8, Math.min(4.2, q.scale * (5 + Math.log2(1 + n.knownNeighborCount))));
ctx!.globalAlpha = selected && !lit ? 0.16 : n.stale ? 0.4 : 0.9;
ctx!.fillStyle = nodeTypeColor(n.nodeTypeName, colors);
ctx!.beginPath();
if (n.nodeTypeName === "companion") { ctx!.moveTo(q.x, q.y - radius); ctx!.lineTo(q.x + radius, q.y + radius); ctx!.lineTo(q.x - radius, q.y + radius); ctx!.closePath(); }
else if (n.nodeTypeName === "room_server" || n.nodeTypeName === "sensor") ctx!.rect(q.x - radius, q.y - radius, radius * 2, radius * 2);
else ctx!.arc(q.x, q.y, radius, 0, Math.PI * 2);
ctx!.fill();
if (n.isObserver || n.id === selected) { ctx!.strokeStyle = n.id === selected ? colors.textBright : colors.primary; ctx!.lineWidth = 1.2; ctx!.beginPath(); ctx!.arc(q.x, q.y, radius + 3, 0, Math.PI * 2); ctx!.stroke(); }
}
ctx!.globalAlpha = 1;
backgroundCtx?.clearRect(0, 0, background.width, background.height); backgroundCtx?.drawImage(canvas, 0, 0); baseDirty = false;
} else ctx!.drawImage(background, 0, 0, width, height);
// All live flows remain visible in every link-display mode. Curves change
// presentation only: their endpoints still come from exact observed segments.
for (const report of flows) {
const progress = Math.min(1, (Date.now() - report.at) / FLOW_MS);
const segmentIndex = Math.min(report.segments.length - 1, Math.floor(progress * report.segments.length));
const pair = report.segments[segmentIndex];
if (!pair) continue;
const a = graph.byId.get(pair[0]), b = graph.byId.get(pair[1]);
if (!a || !b) continue;
const points = curve(a, b, a.region !== b.region);
const fraction = progress * report.segments.length - segmentIndex;
const inv = 1 - fraction;
const x = inv ** 3 * points[0]!.x + 3 * inv ** 2 * fraction * points[1]!.x + 3 * inv * fraction ** 2 * points[2]!.x + fraction ** 3 * points[3]!.x;
const y = inv ** 3 * points[0]!.y + 3 * inv ** 2 * fraction * points[1]!.y + 3 * inv * fraction ** 2 * points[2]!.y + fraction ** 3 * points[3]!.y;
ctx!.strokeStyle = flowColor(report.kind, colors);
ctx!.globalAlpha = 0.55;
ctx!.lineWidth = 1.5;
strokeCurve(points);
ctx!.globalAlpha = 0.2; ctx!.fillStyle = flowColor(report.kind, colors);
ctx!.beginPath(); ctx!.arc(x, y, 8, 0, Math.PI * 2); ctx!.fill();
ctx!.globalAlpha = 1; ctx!.beginPath(); ctx!.arc(x, y, 2.8, 0, Math.PI * 2); ctx!.fill();
}
ctx!.globalAlpha = 1;
ctx!.font = "600 11px Inter, sans-serif";
ctx!.textAlign = "center";
regionHits = [];
const recentByRegion = new Map<string, (typeof traffic.reports)[number]>();
if (motion) for (let i = traffic.reports.length - 1; i >= 0; i--) {
const report = traffic.reports[i]!; if (Date.now() - report.at >= 1500) break;
if (!recentByRegion.has(report.iata)) recentByRegion.set(report.iata, report);
}
for (const region of [...graph.regions].sort((a, b) => Number(b.code === regionCode) - Number(a.code === regionCode) || b.count - a.count)) {
const q = p({ ...region, y: 6 });
if (!q.visible) continue;
const size = Math.min(11, Math.max(8, region.radius * q.scale / 7));
ctx!.font = `600 ${size}px Inter, sans-serif`;
ctx!.globalAlpha = region.code === regionCode ? 1 : Math.min(1, Math.max(0, (region.radius * q.scale - 8) / 30));
if (ctx!.globalAlpha < 0.15) continue;
q.y -= Math.min(35, region.radius * q.scale * 0.6);
if (q.x < -100 || q.x > width + 100 || q.y < -30 || q.y > height + 30) continue;
const label = `${region.code} · ${region.count}`;
const w = ctx!.measureText(label).width + 16;
if (regionHits.some(r => Math.abs(r.x - q.x) < (r.width + w) / 2 + 5 && Math.abs(r.y - q.y) < 29)) continue;
ctx!.fillStyle = colors.bgRaised; ctx!.fillRect(q.x - w / 2, q.y - 13, w, 26);
ctx!.fillStyle = colors.textBright; ctx!.fillText(label, q.x, q.y + 4);
const report = recentByRegion.get(region.code);
if (report) { ctx!.fillStyle = flowColor(report.kind, colors); ctx!.beginPath(); ctx!.arc(q.x - w / 2 - 5, q.y, 3, 0, Math.PI * 2); ctx!.fill(); }
regionHits.push({ code: region.code, x: q.x, y: q.y, width: w });
}
ctx!.globalAlpha = 1;
const selectedPoint = projected.find(n => n.id === selected);
if (selectedPoint) {
const label = (graph.byId.get(selected)!.name || selected.slice(0, 8)).slice(0, 40);
ctx!.font = "600 12px Inter, sans-serif";
const w = ctx!.measureText(label).width + 16;
ctx!.fillStyle = colors.bgSurface; ctx!.fillRect(selectedPoint.x - w / 2, selectedPoint.y - 34, w, 22);
ctx!.fillStyle = colors.textBright; ctx!.fillText(label, selectedPoint.x, selectedPoint.y - 19);
}
const placed: {x:number;y:number;w:number;h:number}[] = [];
for (const bubble of bubbles) {
if (placed.length >= (width < 600 ? 1 : 2)) break;
const node = graph.byId.get(bubble.nodeId);
if (!node || (regionCode && node.region !== regionCode)) continue;
const q=p(node);
if (!q.visible || q.x < 8 || q.x > width-8 || q.y < 8 || q.y > height-8) continue;
const w=Math.min(240,width-24), h=76;
const positions=[{x:q.x-w/2,y:q.y-h-25},{x:q.x+20,y:q.y-h-15},{x:q.x-w-20,y:q.y-h-15},{x:q.x-w/2,y:q.y+25}];
const box=positions.map(pos=>({x:Math.max(8,Math.min(width-w-8,pos.x)),y:Math.max(8,Math.min(height-h-8,pos.y)),w,h})).find(pos=>(q.x<pos.x-8 || q.x>pos.x+pos.w+8 || q.y<pos.y-8 || q.y>pos.y+pos.h+8) && !placed.some(old=>pos.x<old.x+old.w+8 && pos.x+pos.w+8>old.x && pos.y<old.y+old.h+8 && pos.y+pos.h+8>old.y));
if (!box) continue; placed.push(box);
const alpha=motion ? Math.min(1,Math.max(0,(CHAT_MS-(Date.now()-bubble.at))/1600)) : 1;
ctx!.globalAlpha=alpha;
ctx!.strokeStyle=colors.secondary;ctx!.lineWidth=1;
const sx=Math.max(box.x,Math.min(box.x+w,q.x)),sy=Math.max(box.y,Math.min(box.y+h,q.y));
ctx!.beginPath();ctx!.moveTo(sx,sy);ctx!.lineTo(q.x,q.y);ctx!.stroke();
const angle=Math.atan2(q.y-sy,q.x-sx);
ctx!.beginPath();ctx!.moveTo(q.x,q.y);ctx!.lineTo(q.x-7*Math.cos(angle-.45),q.y-7*Math.sin(angle-.45));ctx!.lineTo(q.x-7*Math.cos(angle+.45),q.y-7*Math.sin(angle+.45));ctx!.closePath();ctx!.fillStyle=colors.secondary;ctx!.fill();
ctx!.fillStyle=colors.bgSurface;ctx!.fillRect(box.x,box.y,w,h);ctx!.strokeRect(box.x,box.y,w,h);
ctx!.textAlign="left";ctx!.font="600 12px Inter, sans-serif";ctx!.fillStyle=colors.secondary;
ctx!.fillText(bubble.sender,box.x+9,box.y+18,w-18);
ctx!.font="12px Inter, sans-serif";ctx!.fillStyle=colors.textBright;
const chars=Array.from(bubble.content);const lines:string[]=[];let line="";
for (const char of chars) { if(ctx!.measureText(line+char).width>w-20) { const space=line.lastIndexOf(" "); if(space>0){lines.push(line.slice(0,space));line=line.slice(space+1);} else {lines.push(line);line="";} } line+=char; }
if(line)lines.push(line);
for(let i=0;i<Math.min(2,lines.length);i++)ctx!.fillText(lines[i]!.slice(0,i===1 && lines.length>2 ? -2 : undefined)+(i===1 && lines.length>2 ? "…" : ""),box.x+9,box.y+39+i*17,w-18);
}
ctx!.globalAlpha=1;
if (flows.length || transition || (bubbles.length && motion)) frame = requestAnimationFrame(draw);
}
function resize() {
const box = canvas.getBoundingClientRect(); width = Math.max(1, box.width); height = Math.max(1, box.height);
const ratio = Math.min(window.devicePixelRatio || 1, width < 600 ? 1.25 : 1.5);
canvas.width = background.width = Math.round(width * ratio); canvas.height = background.height = Math.round(height * ratio);
ctx!.setTransform(ratio, 0, 0, ratio, 0, 0);
if (!fitted) { fit(false); fitted = true; } else wake(true);
}
function zoom(factor: number) { transition = null; camera = { ...camera, zoom: Math.max(0.2, Math.min(80, camera.zoom * factor)) }; wake(true); }
function pan(dx: number, dy: number) { transition = null; camera = panCamera(camera, dx, dy, width, height, extent); wake(true); }
function down(e: PointerEvent) {
if (e.button > 2) return;
e.preventDefault(); canvas.focus({ preventScroll: true });
transition = null; start = { x: e.clientX, y: e.clientY }; moved = pointers.size > 0;
panning = e.button !== 2 && !e.shiftKey;
pointers.set(e.pointerId, start); canvas.setPointerCapture(e.pointerId);
}
function move(e: PointerEvent) {
const old = pointers.get(e.pointerId); if (!old) return;
const other = [...pointers].find(([id]) => id !== e.pointerId)?.[1];
const dx = e.clientX - old.x, dy = e.clientY - old.y;
if (other) { const before = Math.hypot(old.x - other.x, old.y - other.y); const after = Math.hypot(e.clientX - other.x, e.clientY - other.y); if (before > 5) zoom(after / before); pan(dx / 2, dy / 2); moved = true; }
else if (panning) pan(dx, dy);
else { camera.yaw += dx * 0.006; camera.pitch = Math.max(0.35, Math.min(Math.PI / 2, camera.pitch + dy * 0.006)); }
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > 5) moved = true;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); wake(true);
}
function up(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.delete(e.pointerId);
if (moved || e.type !== "pointerup" || e.button !== 0) return;
const box = canvas.getBoundingClientRect(), x = e.clientX - box.left, y = e.clientY - box.top;
const region = regionHits.find(r => Math.abs(r.x - x) <= r.width / 2 + 6 && Math.abs(r.y - y) <= 20);
if (region) { focus(region.code); return; }
const hit = [...projected].reverse().find(n => Math.hypot(n.x - x, n.y - y) <= 9);
if (hit) select(hit.id);
}
function contextMenu(e: MouseEvent) { e.preventDefault(); }
function wheel(e: WheelEvent) { e.preventDefault(); zoom(Math.exp(-Math.max(-100, Math.min(100, e.deltaY)) * 0.002)); }
function key(e: KeyboardEvent) {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "Home", "Escape"].includes(e.key)) return;
e.preventDefault(); transition = null;
if (e.key === "Home") fit();
else if (e.key === "Escape") focus("");
else if (e.key === "+" || e.key === "=") zoom(1.2);
else if (e.key === "-") zoom(1 / 1.2);
else {
const dx = e.key === "ArrowLeft" ? 30 : e.key === "ArrowRight" ? -30 : 0, dy = e.key === "ArrowUp" ? 30 : e.key === "ArrowDown" ? -30 : 0;
if (e.shiftKey) { camera.yaw -= dx * 0.005; camera.pitch = Math.max(0.35, Math.min(Math.PI / 2, camera.pitch - dy * 0.005)); }
else pan(dx, dy);
}
wake(true);
}
function visibility() { pointers.clear(); if (document.hidden) { cancelAnimationFrame(frame); frame = 0; } else wake(); }
const observer = new ResizeObserver(resize); observer.observe(canvas); resize();
canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move);
canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); canvas.addEventListener("lostpointercapture", up);
canvas.addEventListener("contextmenu", contextMenu);
canvas.addEventListener("wheel", wheel, { passive: false }); canvas.addEventListener("keydown", key);
document.addEventListener("visibilitychange", visibility);
return {
update(id: string, region: string, paths: PathDisplay, animate: boolean, active: boolean) {
const changeRegion = region !== regionCode;
baseDirty ||= selected !== id || changeRegion || paths !== display || lastTrailVersion !== traffic.trailVersion;
lastTrailVersion = traffic.trailVersion;
selected = id; regionCode = region; display = paths; motion = animate; running = active;
related = new Set([id]);
for (const [a, b] of graph.links) { if (a === id) related.add(b); if (b === id) related.add(a); }
if (!motion && transition) { camera = transition.to; transition = null; baseDirty = true; }
if (changeRegion) fit();
if (!running) { cancelAnimationFrame(frame); frame = 0; } else wake();
},
zoom, fit, angle(top: boolean) { camera = { ...camera, yaw: 0, pitch: top ? Math.PI / 2 : DEFAULT_CAMERA.pitch }; fit(); },
view(): CameraView { const current = transition?.to ?? camera; return { camera: { ...current, target: { ...current.target } }, region: regionCode, extent }; },
dispose() {
running = false; cancelAnimationFrame(frame); observer.disconnect();
canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move);
canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", up); canvas.removeEventListener("lostpointercapture", up);
canvas.removeEventListener("contextmenu", contextMenu);
canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("keydown", key);
document.removeEventListener("visibilitychange", visibility);
},
};
}
+277
View File
@@ -0,0 +1,277 @@
import type { ChartColors } from "../stats/chartTheme";
import { getTopologyLinks, getNodesPage } from "../../api/client";
import type { NodeSummary } from "../nodes/types";
import type { KnownRoute, ResolvedHop } from "../../types/api";
import type { WsPacketObservation } from "../../types/ws";
export const NODE_CAP = 20_000;
export const LINK_CAP = 100_000;
export const REPORT_CAP = 10_000;
export const FLOW_CAP = 512;
export const ROUTE_CAP = 60_000;
export const ROUTE_WINDOWS = { "15m": 900_000, "1h": 3_600_000, "24h": 86_400_000 } as const;
export const LIVE_WINDOW = 60_000;
export const FLOW_MS = 4200;
export type Point3 = { x: number; y: number; z: number };
export type TopologyNode = NodeSummary & Point3 & { region: string };
export type TopologyRegion = Point3 & { code: string; count: number; radius: number };
export type Topology = { nodes: TopologyNode[]; byId: Map<string, TopologyNode>; links: [string, string][]; routeLinks: Set<string>; regions: TopologyRegion[]; bundles: { from: string; to: string; count: number }[]; linksCapped: boolean };
export type PathDisplay = "bundled" | "all" | "selected";
export type PacketKind = "advert" | "text" | "trace" | "ack" | "other";
export type LiveReport = { key: string; hash: string; observerId: string; observerName: string; iata: string; at: number; heardAt: number; kind: PacketKind; scope: string; snr: number | null; segments: [string, string][]; partial: boolean };
// Bounded, sequential, cancellable pages; no query per node or live packet.
export async function loadTopology(iatas: string[] | undefined, signal: AbortSignal) {
const nodes = new Map<string, NodeSummary>();
const cursors = new Set<number>();
let cursor: number | undefined;
for (let page = 0; page < NODE_CAP / 200; page++) {
signal.throwIfAborted();
const result = await getNodesPage(iatas, { cursor, limit: 200, neighbors: true }, signal);
for (const node of result.items) if (nodes.size < NODE_CAP) nodes.set(node.id, node);
if (!result.hasMore) return { nodes: [...nodes.values()], capped: false };
if (result.nextCursor == null || cursors.has(result.nextCursor)) break;
cursor = result.nextCursor;
cursors.add(cursor);
}
return { nodes: [...nodes.values()], capped: true };
}
export async function loadTopologyRoutes(iatas: string[] | undefined, signal: AbortSignal, window = ROUTE_WINDOWS["15m"] as number) {
signal.throwIfAborted();
if (iatas?.length === 0) return { routes: [], capped: false };
const key = Object.entries(ROUTE_WINDOWS).find(([,value])=>value===window)?.[0];
if (!key) throw new Error("Unsupported topology window");
const result = await getTopologyLinks(iatas,key,signal);
return {routes:result.links,capped:result.capped};
}
export function buildTopology(input: NodeSummary[], selectedIatas: string | readonly string[] = "", routes: (KnownRoute | [string,string])[] = []): Topology {
const allowed = typeof selectedIatas === "string" ? (selectedIatas ? [selectedIatas] : undefined) : selectedIatas;
const groups = new Map<string, NodeSummary[]>();
const unique = [...new Map(input.slice(0, NODE_CAP).map(n => [n.id, n])).values()];
for (const node of unique) {
const memberships = allowed ? node.iatas.filter(i => allowed.includes(i.iata)) : node.iatas;
const code = [...memberships].sort((a, b) => b.lastHeard - a.lastHeard || a.iata.localeCompare(b.iata))[0]?.iata || "?";
if (!groups.has(code)) groups.set(code, []);
groups.get(code)!.push(node);
}
const ordered = [...groups].sort(([a], [b]) => a.localeCompare(b));
const columns = Math.max(1, Math.ceil(Math.sqrt(ordered.length)));
const radii = ordered.map(([, members]) => Math.max(85, 14 * Math.sqrt(members.length) + 28));
const columnWidths = Array.from({ length: columns }, (_, c) => Math.max(0, ...radii.filter((_, i) => i % columns === c)) * 2 + 120);
const rowHeights = Array.from({ length: Math.ceil(ordered.length / columns) }, (_, r) => Math.max(...radii.slice(r * columns, (r + 1) * columns)) * 2 + 120);
const centerAt = (sizes: number[], index: number) => sizes.slice(0, index).reduce((a, b) => a + b, 0) + sizes[index]! / 2 - sizes.reduce((a, b) => a + b, 0) / 2;
const nodes: TopologyNode[] = [];
const regions: Topology["regions"] = [];
ordered.forEach(([code, members], regionIndex) => {
const center = { x: centerAt(columnWidths, regionIndex % columns), y: 0, z: centerAt(rowHeights, Math.floor(regionIndex / columns)) };
regions.push({ ...center, radius: radii[regionIndex]!, code, count: members.length });
members.sort((a, b) => b.knownNeighborCount - a.knownNeighborCount || a.id.localeCompare(b.id));
members.forEach((node, i) => {
// A sunflower disc gives every identity space; shallow height keeps the 3D
// view readable instead of projecting a dense sphere onto itself.
const radius = 14 * Math.sqrt(i);
const angle = i * Math.PI * (3 - Math.sqrt(5));
nodes.push({ ...node, region: code, x: center.x + Math.cos(angle) * radius, y: node.isObserver ? 28 : 4 + Math.min(16, node.knownNeighborCount * 2), z: center.z + Math.sin(angle) * radius });
});
});
const byId = new Map(nodes.map(n => [n.id, n]));
const links: [string, string][] = [];
const routeLinks = new Set<string>();
const seen = new Set<string>();
let linksCapped = false;
for (const node of nodes) for (const other of node.neighborIds ?? []) {
if (node.id === other || !byId.has(other)) continue;
const pair = [node.id, other].sort() as [string, string];
const key = pair.join("|");
if (seen.has(key)) continue;
if (links.length >= LINK_CAP) { linksCapped = true; break; }
seen.add(key);
links.push(pair);
}
const routePairs: [string,string][] = routes.flatMap(route => Array.isArray(route) ? [route] : route.hops.slice(1).map((hop,i) => [route.hops[i]!.nodeId,hop.nodeId] as [string,string]));
for (const [a,b] of routePairs) {
// Never remove a missing hop and connect the identities on either side.
if (a === b || !byId.has(a) || !byId.has(b)) continue;
const pair = [a, b].sort() as [string, string], key = pair.join("|");
if (!seen.has(key)) {
if (links.length >= LINK_CAP) { linksCapped = true; continue; }
seen.add(key); links.push(pair);
}
routeLinks.add(key);
}
const bundles = new Map<string, Topology["bundles"][number]>();
for (const [a, b] of links) {
const [from, to] = [byId.get(a)!.region, byId.get(b)!.region].sort();
if (from === to) continue;
const key = `${from}|${to}`, old = bundles.get(key);
if (old) old.count++; else bundles.set(key, { from: from!, to: to!, count: 1 });
}
// Layout runs only when the bounded snapshot changes, never on animation frames.
// Springs use actual neighbour edges; no scope or geographic inference creates links.
const regionIndex = new Map(regions.map((r, i) => [r.code, i]));
const oldCenters = regions.map(r => ({ x: r.x, z: r.z }));
relaxPositions(regions, regions.map(r => r.radius), [...bundles.values()].map(b => [regionIndex.get(b.from)!, regionIndex.get(b.to)!, b.count]), 90, 100);
for (const node of nodes) {
const index = regionIndex.get(node.region)!;
node.x += regions[index]!.x - oldCenters[index]!.x;
node.z += regions[index]!.z - oldCenters[index]!.z;
}
const local = new Map(regions.map(r => [r.code, { nodes: [] as TopologyNode[], edges: [] as [number, number, number][] }]));
const indices = new Map<string, number>();
for (const node of nodes) { const group = local.get(node.region)!; indices.set(node.id, group.nodes.length); group.nodes.push(node); }
for (const [a, b] of links) if (byId.get(a)!.region === byId.get(b)!.region) local.get(byId.get(a)!.region)!.edges.push([indices.get(a)!, indices.get(b)!, 1]);
for (const region of regions) { const group = local.get(region.code)!; relaxPositions(group.nodes, group.nodes.map(() => 7), group.edges, 5, 32, region); }
return { nodes, byId, links, routeLinks, regions, bundles: [...bundles.values()], linksCapped };
}
function relaxPositions(points: Point3[], radii: number[], edges: [number, number, number][], gap: number, iterations: number, boundary?: TopologyRegion) {
if (points.length < 2 || !edges.length) return;
const cellSize = Math.max(...radii) * 2 + gap;
for (let pass = 0; pass < iterations; pass++) {
if (!boundary && pass < iterations - 8) for (const p of points) { p.x *= 0.982; p.z *= 0.982; }
if (pass < iterations - 8) for (const [ai, bi, weight] of edges) {
const a = points[ai]!, b = points[bi]!, dx = b.x - a.x, dz = b.z - a.z;
const distance = Math.hypot(dx, dz) || 1;
const rest = radii[ai]! + radii[bi]! + gap * (1.2 + 1 / Math.sqrt(weight));
const force = Math.max(-8, Math.min(8, (distance - rest) * 0.035));
a.x += dx / distance * force; a.z += dz / distance * force;
b.x -= dx / distance * force; b.z -= dz / distance * force;
}
// Spatial buckets avoid an all-pairs collision pass on the expanded view.
const cells = new Map<string, number[]>();
points.forEach((p, i) => {
const cx = Math.floor(p.x / cellSize), cz = Math.floor(p.z / cellSize);
for (let x = cx - 1; x <= cx + 1; x++) for (let z = cz - 1; z <= cz + 1; z++) for (const j of cells.get(`${x}|${z}`) ?? []) {
const q = points[j]!, dx = p.x - q.x || 0.001, dz = p.z - q.z;
const distance = Math.hypot(dx, dz), minimum = radii[i]! + radii[j]! + gap;
if (distance >= minimum) continue;
const push = (minimum - distance) * 0.51;
p.x += dx / distance * push; p.z += dz / distance * push;
q.x -= dx / distance * push; q.z -= dz / distance * push;
}
const key = `${Math.floor(p.x / cellSize)}|${Math.floor(p.z / cellSize)}`;
if (!cells.has(key)) cells.set(key, []);
cells.get(key)!.push(i);
if (boundary) {
const dx = p.x - boundary.x, dz = p.z - boundary.z, distance = Math.hypot(dx, dz), limit = boundary.radius - 20;
if (distance > limit) { p.x = boundary.x + dx / distance * limit; p.z = boundary.z + dz / distance * limit; }
}
});
}
}
export function packetKind(type: number): PacketKind {
return type === 4 ? "advert" : type === 2 || type === 5 ? "text" : type === 9 ? "trace" : type === 3 ? "ack" : "other";
}
// Only adjacent, single-candidate, high-confidence hops become an animated segment.
// Keep gaps in place: filtering unknown hops first would fabricate a radio link.
export function reportFromEvent(event: WsPacketObservation["data"], graph: Topology, at: number): LiveReport | null {
const obs = event?.observation;
if (!obs || !event.packet || event.packet.isRepeat || typeof event.packetHash !== "string" || !event.packetHash || typeof obs.observerId !== "string" || !obs.observerId || typeof obs.iata !== "string") return null;
const path = obs.resolvedPath;
const hops: (ResolvedHop | null | undefined)[] = Array.isArray(path) ? [obs.resolvedSource, ...path.slice(0, 64), obs.resolvedDestination] : [];
const ids = hops.map(h => h?.confidence === "high" && h.nodes?.length === 1 && graph.byId.has(h.nodes[0]!.id) ? h.nodes[0]!.id : null);
const segments: [string, string][] = [];
for (let i = 1; i < ids.length; i++) {
const a = ids[i - 1], b = ids[i];
if (a && b && a !== b) segments.push([a, b]);
}
return { key: `${event.packetHash}|${obs.observerId}`, hash: event.packetHash, observerId: obs.observerId, observerName: typeof obs.observerName === "string" ? obs.observerName : obs.observerId.slice(0, 8), iata: obs.iata, at, heardAt: obs.heardAt, kind: packetKind(event.packet.payloadType), scope: typeof event.packet.scope === "string" ? event.packet.scope : "", snr: Number.isFinite(obs.snr) ? obs.snr : null, segments, partial: !Array.isArray(path) || path.length > 64 || hops.some(h => h != null && (h.confidence !== "high" || h.nodes?.length !== 1 || !graph.byId.has(h.nodes[0]!.id))) };
}
export class LiveTraffic {
reports: LiveReport[] = [];
flows: LiveReport[] = [];
trails = new Map<string, { from: string; to: string; kind: PacketKind; at: number }>();
trailVersion = 0;
cappedUntil = 0;
gapUntil = 0;
markGap() { this.gapUntil = Date.now() + LIVE_WINDOW; }
private seen = new Map<string, number>();
add(report: LiveReport) {
this.prune(report.at);
if (this.seen.has(report.key)) return false;
this.seen.set(report.key, report.at);
if (this.seen.size > REPORT_CAP * 2) this.seen.delete(this.seen.keys().next().value!);
this.reports.push(report);
for (const [from, to] of report.segments) {
const key = `${from}|${to}`, old = this.trails.get(key);
if (!old || old.kind !== report.kind) this.trailVersion++;
this.trails.delete(key); this.trails.set(key, { from, to, kind: report.kind, at: report.at });
if (this.trails.size > LINK_CAP) { this.trails.delete(this.trails.keys().next().value!); this.cappedUntil = report.at + LIVE_WINDOW; this.trailVersion++; }
}
if (this.reports.length > REPORT_CAP) { this.reports.shift(); this.cappedUntil = report.at + LIVE_WINDOW; }
if (report.segments.length) {
if (this.flows.length >= FLOW_CAP) this.cappedUntil = report.at + LIVE_WINDOW;
this.flows = [...this.flows.slice(-(FLOW_CAP - 1)), report];
}
return true;
}
clearFlows() { this.flows = []; }
prune(now: number) {
this.reports = this.reports.filter(r => now - r.at < LIVE_WINDOW);
this.flows = this.flows.filter(r => now - r.at < FLOW_MS);
for (const [key, at] of this.seen) { if (now - at < LIVE_WINDOW) break; this.seen.delete(key); }
for (const [key, trail] of this.trails) { if (now - trail.at < LIVE_WINDOW) break; this.trails.delete(key); this.trailVersion++; }
}
}
export type Camera = { yaw: number; pitch: number; zoom: number; target: Point3 };
export const DEFAULT_CAMERA: Camera = { yaw: 0, pitch: 1.05, zoom: 1, target: { x: 0, y: 0, z: 0 } };
export function project(point: Point3, camera: Camera, width: number, height: number, extent: number) {
const local = { x: point.x - camera.target.x, y: point.y - camera.target.y, z: point.z - camera.target.z };
const x = local.x * Math.cos(camera.yaw) - local.z * Math.sin(camera.yaw);
const z = local.x * Math.sin(camera.yaw) + local.z * Math.cos(camera.yaw);
// Positive pitch puts the camera above the ground: raised nodes are closer.
const y = local.y * Math.cos(camera.pitch) + z * Math.sin(camera.pitch);
const depth = -local.y * Math.sin(camera.pitch) + z * Math.cos(camera.pitch);
const perspective = 3 * extent / (3 * extent + depth);
const scale = Math.min(width, height) / (extent * 2.6) * camera.zoom * perspective;
return { x: width / 2 + x * scale, y: height / 2 - y * scale, depth, scale, visible: perspective > 0 };
}
export function fitCamera(points: Point3[], camera: Camera, width: number, height: number, extent: number): Camera {
if (!points.length) return { ...DEFAULT_CAMERA, target: { ...DEFAULT_CAMERA.target } };
const target = { x: 0, y: 0, z: 0 };
for (const axis of ["x", "y", "z"] as const) target[axis] = (Math.min(...points.map(p => p[axis])) + Math.max(...points.map(p => p[axis]))) / 2;
let base = { ...camera, target, zoom: 1 };
// Perspective can shift the projected centre away from the world-space centre.
// Recentre before scaling so Fit does not leave the mesh against one edge.
for (let pass = 0; pass < 2; pass++) {
const view = points.map(point => project(point, base, width, height, extent));
const cx = (Math.min(...view.map(p => p.x)) + Math.max(...view.map(p => p.x))) / 2;
const cy = (Math.min(...view.map(p => p.y)) + Math.max(...view.map(p => p.y))) / 2;
base = panCamera(base, width / 2 - cx, height / 2 - cy, width, height, extent);
}
const projected = points.map(point => project(point, base, width, height, extent));
const dx = Math.max(1, ...projected.map(p => Math.abs(p.x - width / 2)));
const dy = Math.max(1, ...projected.map(p => Math.abs(p.y - height / 2)));
return { ...base, zoom: Math.max(0.2, Math.min(80, Math.max(60, width - 100) / (2 * dx), Math.max(60, height - 100) / (2 * dy))) };
}
export function panCamera(camera: Camera, dx: number, dy: number, width: number, height: number, extent: number): Camera {
const scale = Math.min(width, height) / (extent * 2.6) * camera.zoom;
const x = -dx / scale, z = dy / (scale * Math.sin(camera.pitch));
return { ...camera, target: { x: camera.target.x + x * Math.cos(camera.yaw) + z * Math.sin(camera.yaw), y: camera.target.y, z: camera.target.z - x * Math.sin(camera.yaw) + z * Math.cos(camera.yaw) } };
}
export function pathControls(a: Point3, b: Point3, crossRegion: boolean): [Point3, Point3, Point3, Point3] {
const lift = Math.max(a.y, b.y) + (crossRegion ? 100 + Math.hypot(a.x - b.x, a.z - b.z) * 0.24 : 18);
return [a, { ...a, y: lift }, { ...b, y: lift }, b];
}
export function visibleLinks(graph: Topology, display: PathDisplay, region: string, selected: string) {
return graph.links.filter(([a, b]) => display === "all" || a === selected || b === selected ||
(display === "bundled" && !!region && graph.byId.get(a)!.region === region && graph.byId.get(b)!.region === region));
}
export function flowColor(kind: PacketKind, c: ChartColors) {
return { advert: c.green, text: c.secondary, trace: c.warn, ack: c.primary, other: c.textNormal }[kind];
}
export function linkContext(a: TopologyNode, b: TopologyNode) {
return { crossRegion: a.region !== b.region, sharedDefault: a.defaultScope && a.defaultScope === b.defaultScope ? a.defaultScope : null };
}
+2
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { TraceQualityNotice } from "./TraceQualityNotice";
import { getTraceDetail } from "../../api/client";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { Badge } from "../../components/Badge";
@@ -66,6 +67,7 @@ function TracePacketRow({ pkt, onAnalyze, onViewNode }: {
<Field label={t("traces.first")} value={<Timestamp value={pkt.firstHeardAt} ms />} />
<Field label={t("traces.last")} value={<Timestamp value={pkt.lastHeardAt} ms />} />
</div>
<TraceQualityNotice quality={pkt.quality} />
<div className="mt-1.5">
<TraceHopChain rawPath={pkt.rawPath ?? []} resolvedRoute={pkt.resolvedRoute ?? []} onViewNode={onViewNode} />
</div>
+21 -7
View File
@@ -13,8 +13,10 @@ import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters"
import { TraceDetailPanel } from "./TraceDetailPanel";
import type { TraceTagSummary, TraceType } from "../../types/api";
// Traces are modest in number and the list isn't streamed, so a single region-filtered fetch covers
// the card list (the /traces cursor is sound if pagination is ever needed).
import { TraceQualityNotice } from "./TraceQualityNotice";
import { isQuestionable } from "./trace-quality";
// A bounded sample; the hidden count refers only to these loaded tags.
const TRACE_LIST_LIMIT = 200;
interface TraceListProps {
@@ -78,6 +80,7 @@ function TraceTagCard({ tag, selected, onSelect }: {
<span className="font-mono text-xs font-semibold text-primary tracking-wider">{tag.traceTag.toUpperCase()}</span>
{/* pings get the primary tint, traces the amber one, so the two read apart at a glance */}
{tag.traceType && <Badge variant={tag.traceType === "PING" ? "text" : "trace"}>{tag.traceType}</Badge>}
<TraceQualityNotice quality={tag.quality} compact />
<Timestamp value={tag.lastHeardAt} className="ml-auto text-[11px] text-text-dim" />
</div>
<div className="mt-1 text-[11px] text-text-dim font-mono">
@@ -97,6 +100,8 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
{ value: "TRACE", label: t("traces.trace") },
{ value: "PING", label: t("traces.ping") },
], [t]);
const [showQuestionable, setShowQuestionable] = useState(false);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [typeFilter, setTypeFilter] = useState<"" | TraceType>("");
@@ -116,13 +121,22 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
enabled: isResolved !== false,
});
const questionableCount = tags?.filter((tag) => isQuestionable(tag.quality)).length ?? 0;
const visibleTags = tags?.filter((tag) => showQuestionable || !isQuestionable(tag.quality));
const selectedVisible = visibleTags?.some((tag) => tag.traceTag === selectedTag);
return (
<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex flex-wrap shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="font-mono text-[11px] text-text-dim">
{tags ? t("traces.tagCount", { count: tags.length }) : ""}
{tags ? t("traces.tagCount", { count: visibleTags?.length ?? 0 }) : ""}
{questionableCount > 0 && !showQuestionable && <> · {t("traces.quality.hidden", { count: questionableCount })}</>}
</span>
<label className="flex items-center gap-1.5 text-[11px] text-text-muted cursor-pointer">
<input type="checkbox" checked={showQuestionable} onChange={(e) => { setShowQuestionable(e.target.checked); setSelectedTag(null); }} />
{t("traces.quality.show")}
</label>
<div className="flex items-center gap-2">
<InfoTip text={t("traces.typeHint")} />
<Segmented
@@ -136,16 +150,16 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) {
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{isLoading || isResolved === false ? (
<SkeletonRows rows={8} />
) : (tags?.length ?? 0) === 0 ? (
) : (visibleTags?.length ?? 0) === 0 ? (
<EmptyState title={t("traces.empty")} />
) : (
tags!.map((tag) => (
visibleTags!.map((tag) => (
<TraceTagCard key={tag.traceTag} tag={tag} selected={tag.traceTag === selectedTag} onSelect={setSelectedTag} />
))
)}
</div>
</div>
{selectedTag && (
{selectedTag && selectedVisible && (
<TraceDetailPanel tag={selectedTag} onClose={() => setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} />
)}
</div>
@@ -0,0 +1,14 @@
import { isQuestionable } from "./trace-quality";
import { useTranslation } from "react-i18next";
import type { TraceQuality } from "../../types/api";
export function TraceQualityNotice({ quality, compact = false }: { quality?: TraceQuality; compact?: boolean }) {
const { t } = useTranslation();
if (!isQuestionable(quality)) return null;
const reasons = quality!.reasons.map((reason) => t(`traces.quality.${reason}`, { defaultValue: reason })).join(" · ");
return <div className="text-warn text-[11px]" title={reasons}>
<span className="font-medium">{t(`traces.quality.${quality!.status}`)}</span>
{!compact && <><div>{reasons}</div><div className="text-text-dim">{t("traces.quality.evidenceOnly")}</div></>}
</div>;
}
+5
View File
@@ -0,0 +1,5 @@
import type { TraceQuality } from "../../types/api";
export function isQuestionable(quality?: TraceQuality): boolean {
return quality?.status === "suspect" || quality?.status === "ambiguous";
}
+126 -1
View File
@@ -14,7 +14,8 @@
"Routes": "Routes",
"Traces": "Traces",
"Analytics": "Analytics",
"MyAtlas": "My Atlas"
"MyAtlas": "My Atlas",
"Topology": "Topology"
},
"navigation": {
"primary": "Primary",
@@ -787,6 +788,23 @@
"lastSeen": "Last seen"
},
"traces": {
"quality": {
"snrBytes": "Recorded SNR bytes",
"show": "Show possible collision related observations",
"hidden": "{{count}} hidden in loaded sample",
"suspect": "Suspect observation",
"ambiguous": "Possible prefix collision",
"evidenceOnly": "Raw evidence is retained. This does not establish a real route or identify a confirmed collision.",
"unsupported_version": "Unsupported packet version",
"non_direct_trace": "TRACE does not support flood routing",
"incomplete_payload": "Incomplete trace payload",
"payload_too_large": "Payload exceeds the protocol limit",
"unsupported_flags": "Unrecognized trace flags",
"misaligned_hashes": "Hash bytes do not match the declared width",
"too_many_hops": "Route exceeds the supported trace hop count",
"invalid_snr_path": "SNR path cannot match this trace",
"ambiguous_prefix": "A hop matches multiple known nodes"
},
"packets": "Packets",
"packetCount_one": "{{count}} packet",
"packetCount_other": "{{count}} packets",
@@ -1014,6 +1032,113 @@
"dragToReorder": "Drag to reorder",
"remove": "Remove",
"observerPartialCoverage": "Partial history"
},
"topology": {
"publicChatter": "Public chatter",
"chatterHint": "Live Public messages point to the earliest mapped receiver reported in this view. Sender names are message text, not verified identities.",
"title": "Topology",
"eyebrow": "LIVE TOPOLOGY / 3D",
"canvas": "3D topology viewed from above. Left-drag pans; right-drag or Shift-left-drag rotates. Mouse wheel zooms. Arrow keys pan, Shift-arrows rotate, plus and minus zoom, Home fits the view and Escape returns to the mesh. Use Find for keyboard selection.",
"projection": "REGIONAL TOPOLOGY · 3D",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out",
"fit": "Fit view",
"controls": "Click a region to focus · drag to rotate · Shift-drag to pan · pinch or scroll to zoom",
"cameraRegion": "Camera focus",
"overview": "Mesh overview",
"isolate": "Isolate {{region}}",
"showAllRegions": "Show all regions",
"linkView": "Path display",
"allPaths": "All paths",
"bundledPaths": "Region bundles",
"selectedPaths": "Selected node",
"orbit": "Rotate",
"pan": "Pan",
"topView": "Top view",
"paused": "Paused",
"live": "Live",
"reconnecting": "Connecting…",
"region": "Region",
"allRegions": "All regions",
"resume": "Resume",
"pause": "Pause",
"copy": "Copy link",
"copied": "Copied",
"nodes": "Loaded nodes",
"links": "Connections",
"reports": "Reports · last 60s in this view",
"observers": "Reporting observers · last 60s",
"loadError": "Topology could not be loaded.",
"refresh": "Refresh topology",
"loading": "Loading topology…",
"bounded": "A display limit was reached: 20,000 nodes, 60,000 recent routes, 100,000 links or live segments, 10,000 recent reports, or 512 simultaneous animations. Isolate a region for more local detail.",
"gap": "Live coverage has a gap after pausing, backgrounding or reconnecting.",
"observerRing": "Ring = observer",
"reducedMotion": "Reduced motion",
"motion": "Animate packets",
"mix": "Live packet mix · 60s",
"paths": "{{count}} with drawable segments",
"find": "Find",
"search": "Name or public key",
"noNodes": "No matching nodes in the loaded view.",
"neighbours": "{{count}} connections in this view",
"nodeDetails": "Node details",
"observerDetails": "Observer",
"clear": "Clear focus",
"reporting": "Reporting observers",
"listening": "Listening for packet reports…",
"latest": "Latest packet reports",
"partialPath": "Partial path",
"path": "Resolved path",
"noPath": "No drawable path",
"about": "View details",
"definition": "Regions are arranged by their neighbour and recent route connections, with more space for larger groups. Nodes use their most recently heard IATA; isolating an IATA includes all loaded nodes heard there. This is a connection diagram, not geography. All paths shows every loaded neighbour link, resolved route segment heard within the selected route window, and 60 seconds of observed path trails. Region bundles combines links between each pair of regions; its numbers count distinct loaded connections. Live animations remain enabled in every path display. Only adjacent hops resolved to one known identity form observed segments; gaps and ambiguous prefixes are never joined. Curves and motion illustrate connections, not physical altitude, radio travel time or delivery. Live counts are packet/observer pairs received in this browser during the last 60 seconds, deduplicated within that window; hidden or paused time is not backfilled. The finder searches loaded nodes. Refresh updates the snapshot.",
"types": {
"companion": "Companion",
"repeater": "Repeater",
"room_server": "Room",
"sensor": "Sensor"
},
"kinds": {
"advert": "Advert",
"text": "Text",
"trace": "Trace",
"ack": "ACK",
"other": "Other"
},
"crossRegion": "Between IATAs",
"sharedDefault": "Same default scope",
"defaultScope": "Advertised default scope",
"catalogueFresh": "Cached regional catalogue",
"catalogueStale": "Stale or unavailable source check",
"catalogueCounts": "{{count}} / {{total}} public repeaters carry a scope",
"catalogueChecked": "Last checked",
"catalogueError": "MeshMapper metadata is unavailable.",
"noCatalogue": "No MeshMapper source configured for this IATA.",
"catalogueMeaning": "Regional counts only; not proof that these neighbours forward each scope. ◉ = monitored, including zero-count names.",
"cataloguedLink": "Listed in a regional MeshMapper catalogue; not per-link forwarding evidence.",
"neighbourLimit": "Showing the first 12 of {{count}}.",
"loadingRoutes": "Loading observed routes…",
"routesError": "Observed routes could not be loaded. Neighbours and live traffic are still available.",
"routeSegments": "Observed routes",
"routeWindow": "Route history",
"windows": {
"15m": "Routes · 15m",
"1h": "Routes · 1h",
"24h": "Routes · 24h"
},
"expand": "Full screen",
"collapse": "Exit full screen",
"display": "Display",
"threeDView": "3D view",
"dragAction": "Drag to",
"inspector": "Node details",
"close": "Close",
"hideInspector": "Close",
"activity": "Live activity",
"activityCounts": "Reports {{count}} · Observers {{observers}} · 60s",
"incomplete": "Gaps",
"controlsPan": "Left-drag to pan. Right-drag or Shift-drag to orbit. Scroll to zoom. Arrow keys pan; Shift+arrows orbit. Home fits the view."
}
}
}
+126 -1
View File
@@ -14,7 +14,8 @@
"Routes": "Routes",
"Traces": "Traces",
"Analytics": "Analyses",
"MyAtlas": "Mon Atlas"
"MyAtlas": "Mon Atlas",
"Topology": "Topologie"
},
"navigation": {
"primary": "Navigation principale",
@@ -770,6 +771,23 @@
"lastSeen": "Dernière réception"
},
"traces": {
"quality": {
"snrBytes": "Octets SNR enregistrés",
"show": "Afficher les observations possiblement liées à une collision",
"hidden": "{{count}} masquées dans cet échantillon",
"suspect": "Observation suspecte",
"ambiguous": "Collision de préfixe possible",
"evidenceOnly": "Les données brutes sont conservées. Elles ne prouvent ni un trajet réel ni une collision confirmée.",
"unsupported_version": "Version de paquet non prise en charge",
"non_direct_trace": "TRACE ne prend pas en charge la diffusion",
"incomplete_payload": "Charge utile de trace incomplète",
"payload_too_large": "Charge utile supérieure à la limite du protocole",
"unsupported_flags": "Indicateurs de trace non reconnus",
"misaligned_hashes": "Octets incompatibles avec la largeur annoncée",
"too_many_hops": "Trajet dépassant le nombre de sauts pris en charge",
"invalid_snr_path": "Le chemin SNR ne correspond pas à cette trace",
"ambiguous_prefix": "Un saut correspond à plusieurs nœuds connus"
},
"packets": "Paquets",
"packetCount_one": "{{count}} paquet",
"packetCount_other": "{{count}} paquets",
@@ -1017,6 +1035,113 @@
"dragToReorder": "Glisser pour réordonner",
"remove": "Retirer",
"observerPartialCoverage": "Historique partiel"
},
"topology": {
"publicChatter": "Messages Public",
"chatterHint": "Les messages Public en direct pointent vers le premier récepteur cartographié signalé dans cette vue. Les noms des expéditeurs ne sont pas des identités vérifiées.",
"title": "Topologie",
"eyebrow": "TOPOLOGIE EN DIRECT / 3D",
"canvas": "Topologie 3D vue du dessus. Glisser avec le bouton gauche déplace la vue ; le bouton droit ou Maj-gauche la fait tourner. La molette zoome. Les flèches déplacent, Maj-flèches tournent, plus et moins zooment, Début cadre la vue et Échap revient au mesh. Utilisez Chercher pour la sélection au clavier.",
"projection": "TOPOLOGIE RÉGIONALE · 3D",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière",
"fit": "Cadrer la vue",
"controls": "Cliquer sur une région pour la centrer · glisser pour tourner · Maj-glisser pour déplacer · pincer ou défiler pour zoomer",
"cameraRegion": "Centrage de la caméra",
"overview": "Vue d’ensemble du mesh",
"isolate": "Isoler {{region}}",
"showAllRegions": "Afficher toutes les régions",
"linkView": "Affichage des chemins",
"allPaths": "Tous les chemins",
"bundledPaths": "Liens regroupés par région",
"selectedPaths": "Nœud sélectionné",
"orbit": "Tourner",
"pan": "Déplacer",
"topView": "Vue du dessus",
"paused": "En pause",
"live": "En direct",
"reconnecting": "Connexion…",
"region": "Région",
"allRegions": "Toutes les régions",
"resume": "Reprendre",
"pause": "Pause",
"copy": "Copier le lien",
"copied": "Copié",
"nodes": "Nœuds chargés",
"links": "Connexions",
"reports": "Rapports · dernières 60 s de cette vue",
"observers": "Observateurs · dernières 60 s",
"loadError": "Impossible de charger la topologie.",
"refresh": "Actualiser la topologie",
"loading": "Chargement de la topologie…",
"bounded": "Une limite d’affichage a été atteinte : 20 000 nœuds, 60 000 routes récentes, 100 000 liens ou segments en direct, 10 000 rapports récents, ou 512 animations simultanées. Isolez une région pour plus de détails locaux.",
"gap": "La couverture en direct comporte une interruption après une pause, une mise en arrière-plan ou une reconnexion.",
"observerRing": "Anneau = observateur",
"reducedMotion": "Mouvements réduits",
"motion": "Animer les paquets",
"mix": "Types de paquets en direct · 60 s",
"paths": "{{count}} avec des segments traçables",
"find": "Chercher",
"search": "Nom ou clé publique",
"noNodes": "Aucun nœud correspondant dans la vue chargée.",
"neighbours": "{{count}} connexions dans cette vue",
"nodeDetails": "Détails du nœud",
"observerDetails": "Observateur",
"clear": "Effacer la sélection",
"reporting": "Observateurs ayant reçu des paquets",
"listening": "En attente de rapports de paquets…",
"latest": "Derniers rapports de paquets",
"partialPath": "Chemin partiel",
"path": "Chemin résolu",
"noPath": "Aucun chemin traçable",
"about": "Détails de la vue",
"definition": "Les régions sont placées selon leurs liens de voisinage et routes récentes, avec plus d’espace pour les grands groupes. Les nœuds utilisent leur IATA de réception le plus récent ; isoler un IATA inclut les nœuds chargés entendus dans cette région. Ce schéma représente les connexions, pas la géographie. Tous les chemins affiche chaque lien chargé, les segments de routes résolus entendus pendant la période sélectionnée et les chemins observés pendant 60 secondes. Les liens regroupés par région portent le nombre de connexions distinctes correspondantes. Les animations restent actives dans chaque mode. Seuls les sauts adjacents résolus vers une identité connue forment des segments ; les lacunes et préfixes ambigus ne sont jamais reliés. Courbes et mouvements ne mesurent ni altitude, ni temps de transmission, ni livraison. Les compteurs représentent les paires paquet/observateur reçues dans ce navigateur pendant 60 secondes, dédupliquées sur cette période ; les pauses et périodes en arrière-plan ne sont pas rattrapées. La recherche porte sur les nœuds chargés. Actualiser met à jour l’instantané.",
"types": {
"companion": "Compagnon",
"repeater": "Répéteur",
"room_server": "Salon",
"sensor": "Capteur"
},
"kinds": {
"advert": "Annonce",
"text": "Texte",
"trace": "Trace",
"ack": "ACK",
"other": "Autre"
},
"crossRegion": "Entre IATA",
"sharedDefault": "Même scope par défaut",
"defaultScope": "Scope par défaut annoncé",
"catalogueFresh": "Catalogue régional en cache",
"catalogueStale": "Vérification de la source ancienne ou indisponible",
"catalogueCounts": "{{count}} / {{total}} répéteurs publics portent un scope",
"catalogueChecked": "Dernière vérification",
"catalogueError": "Les métadonnées MeshMapper sont indisponibles.",
"noCatalogue": "Aucune source MeshMapper configurée pour cet IATA.",
"catalogueMeaning": "Comptages régionaux uniquement ; ils ne prouvent pas que ces voisins transmettent chaque scope. ◉ = surveillé, même à zéro.",
"cataloguedLink": "Présent dans un catalogue régional MeshMapper ; ce n’est pas une preuve de transmission sur ce lien.",
"neighbourLimit": "Les 12 premiers sur {{count}} sont affichés.",
"loadingRoutes": "Chargement des routes observées…",
"routesError": "Impossible de charger les routes observées. Les voisins et le trafic en direct restent disponibles.",
"routeSegments": "Routes observées",
"routeWindow": "Historique des routes",
"windows": {
"15m": "Routes · 15 min",
"1h": "Routes · 1h",
"24h": "Routes · 24h"
},
"expand": "Plein écran",
"collapse": "Quitter le plein écran",
"display": "Affichage",
"threeDView": "Vue 3D",
"dragAction": "Glisser pour",
"inspector": "Détails du nœud",
"close": "Fermer",
"hideInspector": "Fermer",
"activity": "Activité en direct",
"activityCounts": "Rapports {{count}} · Observateurs {{observers}} · 60 s",
"incomplete": "Interruptions",
"controlsPan": "Glissez à gauche pour déplacer, à droite ou avec Maj pour pivoter. Molette pour zoomer. Flèches pour déplacer, Maj+flèches pour pivoter. Début recadre."
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000;
export const RATE_LIMIT_MAX_MS = 300_000;
// app tab names, in display order; the ?tab URL param is validated against this list
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics", "MyAtlas"] as const;
export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas"] as const;
// Per-deployment config: read via runtimeEnv, so the Docker image's /config.js (written at container
// start, see .build/) overrides the VITE_* build-time env.
+10
View File
@@ -60,6 +60,8 @@ export interface PathLength {
}
export interface Observation {
wirePathBytes?: string; // Exact on-air SNR bytes for TRACE; pathBytes is the decoded requested route.
traceQuality?: TraceQuality;
id: number;
observerId: string;
observerName?: string;
@@ -99,6 +101,7 @@ export interface TransportCodes {
}
export interface PacketDetail {
traceQuality?: TraceQuality;
packetHash: string;
header: PacketHeader;
transportCodes?: TransportCodes;
@@ -216,7 +219,13 @@ export interface CrossIATARoute {
// Each tag is either a route TRACE or a PING (round-trip) — the backend tags which.
export type TraceType = "TRACE" | "PING";
export interface TraceQuality {
status: "supported" | "suspect" | "ambiguous";
reasons: string[];
}
export interface TraceTagSummary {
quality?: TraceQuality;
traceTag: string; // hex-encoded 4-byte tag
firstHeardAt: number; // epoch ms
lastHeardAt: number; // epoch ms
@@ -233,6 +242,7 @@ export interface RawHop {
}
export interface TracePacket {
quality?: TraceQuality;
packetHash: string;
routeType: number;
routeTypeName: string;
+24 -2
View File
@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { StrictMode } from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { SelectionResetOnRegion } from "../src/App";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { RegionUrlSync, SelectionResetOnRegion } from "../src/App";
import { RegionProvider, useRegionSelection } from "../src/hooks/useRegion";
function RegionChanger() {
@@ -9,6 +10,27 @@ function RegionChanger() {
return <button onClick={() => setSelection({ regions: [], iatas: ["YYZ"] })}>change</button>;
}
function CurrentUrl() {
return <output data-testid="current-url">{useLocation().search}</output>;
}
it("preserves an initial Topology link but clears its node/camera focus when the shared region changes", async () => {
render(<StrictMode><MemoryRouter initialEntries={["/?tab=Topology&iata=YVR&topoNode=node-a&topoFocus=YVR&topoWindow=24h"]}>
<RegionProvider defaultSelection={{ regions: [], iatas: ["YVR"] }}>
<RegionUrlSync /><RegionChanger /><CurrentUrl />
</RegionProvider>
</MemoryRouter></StrictMode>);
expect(screen.getByTestId("current-url")).toHaveTextContent("topoNode=node-a");
fireEvent.click(screen.getByText("change"));
await waitFor(() => {
const params = new URLSearchParams(screen.getByTestId("current-url").textContent!);
expect(params.get("iata")).toBe("YYZ");
expect(params.get("topoWindow")).toBe("24h");
expect(params.has("topoNode")).toBe(false);
expect(params.has("topoFocus")).toBe(false);
});
});
describe("SelectionResetOnRegion", () => {
// Regression: a deep-linked ?node/?observer selection must survive load. The reset watches the raw
// selection (not the resolved regionKey) so the async slug→IATA expansion doesn't count as a change,
+7 -2
View File
@@ -25,7 +25,8 @@ describe("buildPacketPaths", () => {
const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 181, 10).nodes, ...hop("valid", 0, 45).nodes] };
const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)];
const ordinary = buildPacketPaths(detail([obs(1, hops)]));
const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial<PacketDetail>));
const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 2 },
parsedPayload: { snrValues: [1, 2] }, resolvedRoute: hops } as Partial<PacketDetail>));
expect(ordinary).toEqual([]); expect(trace).toEqual([]);
const valid = buildPacketPaths(detail([obs(1, [hop("start", 0, 45), hop("end", -75, 0)])]));
expect(valid[0].points.map(p => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]);
@@ -128,11 +129,15 @@ describe("buildPacketPaths", () => {
const d = detail(
[obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })],
{
header: { payloadType: PayloadType.TRACE, routeType: 1 },
header: { payloadType: PayloadType.TRACE, routeType: 2 },
parsedPayload: { snrValues: [1, 2] },
resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)],
} as unknown as Partial<PacketDetail>,
);
expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]);
expect(buildPacketPaths({ ...d, traceQuality: { status: "suspect", reasons: ["unsupported_flags"] } })).toEqual([]);
expect(buildPacketPaths({ ...d, traceQuality: { status: "ambiguous", reasons: ["ambiguous_prefix"] } })).toEqual([]);
expect(buildPacketPaths({ ...d, parsedPayload: { snrValues: [1] } })).toEqual([]);
});
it("draws nothing for a TRACE with no resolved route", () => {
@@ -234,9 +234,7 @@ describe("PacketAnalyzerDrawer view-path button", () => {
});
describe("PacketAnalyzerDrawer TRACE path data", () => {
// After beacon-server's trace-path fix (7a58a07) a TRACE observation's pathBytes are the trace's
// own path hashes (with matching hashSize/hopCount and a real resolvedPath), not raw SNR bytes —
// so it must render as resolved Path Data, not under the old "Path SNR Data" label.
// TRACE wire path carries SNR bytes; requested hashes belong to its payload.
function traceDetail(): PacketDetail {
return {
packetHash: "abcdef12",
@@ -246,21 +244,21 @@ describe("PacketAnalyzerDrawer TRACE path data", () => {
observations: [{
id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b",
pathLength: { raw: "02", hashSize: 1, hopCount: 2 },
pathBytes: "abcd",
pathBytes: "abcd", wirePathBytes: "fc00",
resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)],
}],
} as unknown as PacketDetail;
}
it("renders TRACE path bytes as resolved Path Data, not raw 'Path SNR Data'", () => {
it("shows trace wire SNR separately from requested payload hashes", () => {
render(
<MemoryRouter initialEntries={["/?tab=Packets"]}>
<PacketAnalyzerDrawer detail={traceDetail()} selectedObservationId={null} onClose={() => {}} />
</MemoryRouter>,
);
expect(screen.queryByText("Path SNR Data")).not.toBeInTheDocument();
expect(screen.getByText("Path Data")).toBeInTheDocument();
// the first trace hash renders as a resolved hop block, tinted green for high confidence
expect(screen.getAllByText("AB").some((el) => el.className.includes("text-green"))).toBe(true);
expect(screen.queryByText("Path Data")).not.toBeInTheDocument();
expect(screen.getByText("Recorded SNR bytes")).toBeInTheDocument();
expect(screen.getByText("FC00")).toBeInTheDocument();
});
});
@@ -171,3 +171,13 @@ describe("transport frames", () => {
expect(frame.slice(ranges.payload!.start * 2, ranges.payload!.end * 2)).toBe("3b5c22");
});
});
it("uses exact trace SNR bytes in the wire frame instead of decoded requested hashes", () => {
const d = makeDetail({payloadType:PayloadType.TRACE,rawPayload:"010203040000000001aabbccdd"});
d.header.raw="26";
const o = {...makeObs({raw:"01",hashSize:2,hopCount:2,pathBytes:"aabbccdd"}),wirePathBytes:"fc"};
const frame=buildObservationFrame(d,o);
expect(frame).toBe("2601fc010203040000000001aabbccdd");
expect(computeFieldRanges(d,o,frame.length/2).pathData).toEqual({start:2,end:3});
expect(computeFieldRanges(d,o,frame.length/2).payload?.start).toBe(3);
});
+15
View File
@@ -39,3 +39,18 @@ it("draws isolated samples and dashed gap connectors without filling missing rea
expect(container.querySelectorAll("[data-gap]")).toHaveLength(1);
expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3");
});
it("keeps isolated readings visible without connecting across missing hours", () => {
const { container } = render(<StatCard label="Signal" value="2 dB" accent="green" spark={[0, null, 4]} />);
expect(container.querySelectorAll("polyline")).toHaveLength(0);
const points = [...container.querySelectorAll("[data-sample]")];
expect(points.map(point => point.getAttribute("x1"))).toEqual(["0", "120"]);
});
it("draws a single known value at a finite position, including zero", () => {
const { container } = render(<StatCard label="Signal" value="0 dB" accent="green" spark={[0]} />);
const point = container.querySelector("[data-sample]");
expect(point).not.toBeNull();
expect(point?.getAttribute("x1")).toBe("60");
expect(Number.isFinite(Number(point?.getAttribute("y1")))).toBe(true);
});
@@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRenderer } from "../../../src/features/topology/topology-renderer";
import { buildTopology, LiveTraffic } from "../../../src/features/topology/topology";
import type { NodeSummary } from "../../../src/features/nodes/types";
import type { ChartColors } from "../../../src/features/stats/chartTheme";
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
describe("topology camera continuity", () => {
it("keeps pan, angle, zoom and regional focus through a data refresh and resize", () => {
const callbacks: ResizeObserverCallback[] = [];
vi.stubGlobal("ResizeObserver", class { constructor(cb: ResizeObserverCallback) { callbacks.push(cb); } observe() {} disconnect() {} });
vi.stubGlobal("requestAnimationFrame", vi.fn(() => 1));
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ setTransform: vi.fn() } as unknown as CanvasRenderingContext2D);
const canvas = document.createElement("canvas");
vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue({ width: 1000, height: 700 } as DOMRect);
const node = { id: "a", publicKey: "ab".repeat(32), name: "Repeater", nodeType: 2, iatas: [{ iata: "YKF", lastHeard: 1 }], knownNeighborCount: 0, neighborIds: [] } as unknown as NodeSummary;
const graph = buildTopology([node]);
const first = createRenderer(canvas, graph, new LiveTraffic(), {} as ChartColors, vi.fn(), vi.fn())!;
first.update("", "YKF", "all", false, true);
first.zoom(2);
canvas.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", shiftKey: true }));
canvas.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft" }));
const chosen = first.view();
first.dispose();
const refreshed = createRenderer(canvas, buildTopology([{ ...node, name: "Fresh name" }]), new LiveTraffic(), {} as ChartColors, vi.fn(), vi.fn(), chosen)!;
refreshed.update("", "YKF", "all", false, true);
expect(refreshed.view()).toEqual(chosen);
callbacks.at(-1)!([], {} as ResizeObserver);
expect(refreshed.view()).toEqual(chosen);
refreshed.fit(false);
expect(refreshed.view().camera.zoom).not.toBe(chosen.camera.zoom);
refreshed.dispose();
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import type { ReactNode } from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TopologyPage } from "../../../src/features/topology/TopologyPage";
import type { WsManager } from "../../../src/api/ws-manager";
import type { WsPacketObservation } from "../../../src/types/ws";
import { getScopeCatalogues, getNodesPage, getTopologyLinks } from "../../../src/api/client";
import i18n from "../../../src/i18n";
vi.mock("../../../src/features/topology/TopologyCanvas", () => ({ TopologyCanvas: ({ heading, controls, settings }: { heading: ReactNode; controls: ReactNode; settings: ReactNode }) => <div><header>{heading}{controls}</header>{settings}canvas</div> }));
const region = vi.hoisted(() => ({ iatas: ["YOW"] as string[] | undefined, regionKey: "YOW", isResolved: true }));
vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region }));
vi.mock("../../../src/features/stats/chartTheme", () => ({ useChartColors: () => ({}), nodeTypeColor: () => "#fff" }));
vi.mock("../../../src/api/client", () => ({ getIatas: vi.fn(async () => [{ iata: "YOW" }]), getScopeCatalogues: vi.fn(async () => []), getNodesPage: vi.fn(async () => ({ items: [], nextCursor: null, hasMore: false })), getTopologyLinks: vi.fn(async () => ({ links: [], capped: false, since: 0, until: 1 })) }));
beforeEach(() => { region.iatas = ["YOW"]; region.regionKey = "YOW"; region.isResolved = true; });
afterEach(() => vi.useRealTimers());
describe("Topology live lifecycle", () => {
it("keeps traffic live, filters regions, cleans subscriptions, and degrades without catalogue support", async () => {
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
let receive: ((event: WsPacketObservation["data"]) => void) | undefined;
const unsubscribe = vi.fn(), resolve = vi.fn();
const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: resolve, onChannelMessage: () => vi.fn(), onPacketObservation: vi.fn(fn => { receive = fn; return unsubscribe; }) } as unknown as WsManager;
vi.mocked(getScopeCatalogues).mockRejectedValueOnce(new Error("older server"));
const props = { wsManager: manager, onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() };
const { unmount } = render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><MemoryRouter initialEntries={["/?tab=Topology&topoRegion=YOW"]}><TopologyPage {...props} /></MemoryRouter></QueryClientProvider>);
expect(screen.queryByRole("complementary")).not.toBeInTheDocument();
expect(getScopeCatalogues).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Find" }));
await screen.findByText("MeshMapper metadata is unavailable.");
expect(resolve).toHaveBeenCalledWith(true);
const event = { packetHash: "abcdef012345", packet: { payloadType: 99 }, observation: { observerId: "reporter", observerName: "Live observer", iata: "YKF" } } as WsPacketObservation["data"];
act(() => { receive!(event); vi.advanceTimersByTime(600); });
expect(screen.queryByText("abcdef01")).not.toBeInTheDocument();
act(() => { receive!({ ...event, observation: { ...event.observation, iata: "YOW" } }); vi.advanceTimersByTime(600); });
expect(screen.queryByRole("button", { name: /abcdef01/ })).not.toBeInTheDocument();
const activity = screen.getByText("Live activity").closest("details")!;
activity.open = true; fireEvent(activity, new Event("toggle"));
fireEvent.click(screen.getByRole("button", { name: /abcdef01/ })); expect(props.onAnalyzePacket).toHaveBeenCalledWith("abcdef012345");
expect(screen.queryByRole("button", { name: /Pause|Resume/ })).not.toBeInTheDocument();
expect(unsubscribe).not.toHaveBeenCalled();
unmount(); expect(unsubscribe).toHaveBeenCalledTimes(1); expect(resolve).toHaveBeenLastCalledWith(false);
});
it("refreshes route evidence when switching back to a cached window", async () => {
vi.mocked(getTopologyLinks).mockClear();
const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: () => vi.fn() } as unknown as WsManager;
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}><MemoryRouter><TopologyPage wsManager={manager} onViewNode={vi.fn()} onViewObserver={vi.fn()} onAnalyzePacket={vi.fn()} /></MemoryRouter></QueryClientProvider>);
await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(1));
const range = screen.getByRole("combobox", { name: "Route history" });
fireEvent.change(range, { target: { value: "24h" } });
await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(2));
fireEvent.change(range, { target: { value: "15m" } });
await waitFor(() => expect(getTopologyLinks).toHaveBeenCalledTimes(2));
});
it("follows the shared region selector instead of a stale Topology-only filter", async () => {
vi.mocked(getNodesPage).mockClear();
region.iatas = ["YKF"]; region.regionKey = "YKF";
const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onLagged: () => vi.fn(), setResolvePath: vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: () => vi.fn() } as unknown as WsManager;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const page = () => <QueryClientProvider client={client}><MemoryRouter initialEntries={["/?tab=Topology&topoRegion=YOW"]}><TopologyPage wsManager={manager} onViewNode={vi.fn()} onViewObserver={vi.fn()} onAnalyzePacket={vi.fn()} /></MemoryRouter></QueryClientProvider>;
const view = render(page());
await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(["YKF"], expect.anything(), expect.any(AbortSignal)));
expect(screen.queryByRole("combobox", { name: "Region", exact: true })).not.toBeInTheDocument();
region.iatas = ["YVR", "YYJ"]; region.regionKey = "YVR,YYJ"; view.rerender(page());
await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(["YVR", "YYJ"], expect.anything(), expect.any(AbortSignal)));
region.iatas = undefined; region.regionKey = "*"; view.rerender(page());
await waitFor(() => expect(getNodesPage).toHaveBeenLastCalledWith(undefined, expect.anything(), expect.any(AbortSignal)));
});
it("does not fetch a retained inactive view and translates the controls", async () => {
await i18n.changeLanguage("fr"); vi.mocked(getNodesPage).mockClear();
const manager = { getStatus: () => "connected", onStatusChange: () => vi.fn(), onChannelMessage: () => vi.fn(), onPacketObservation: vi.fn() } as unknown as WsManager;
render(<QueryClientProvider client={new QueryClient()}><MemoryRouter><TopologyPage active={false} wsManager={manager} onViewNode={vi.fn()} onViewObserver={vi.fn()} onAnalyzePacket={vi.fn()} /></MemoryRouter></QueryClientProvider>);
expect(screen.getByRole("heading", { name: "Topologie" })).toBeInTheDocument(); expect(getNodesPage).not.toHaveBeenCalled(); expect(manager.onPacketObservation).not.toHaveBeenCalled();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { LiveChatter, CHAT_MS } from "../../../src/features/topology/chatter";
import { buildTopology } from "../../../src/features/topology/topology";
import type { NodeSummary } from "../../../src/features/nodes/types";
import type { WsPacketObservation } from "../../../src/types/ws";
const nodes = ["a", "b"].map(id => ({ id, publicKey:id.repeat(64), observerId:`obs-${id}`, isObserver:true, nodeTypeName:"repeater", nodeType:2, iatas:[{iata:"YKF",lastHeard:1}], knownNeighborCount:0, name:id, lat:null,lng:null } satisfies NodeSummary));
const graph = buildTopology(nodes);
const message = {id:1,packetHash:"hash",isPublic:true,channelHash:"11",senderName:"Alice",content:"hello",sentAt:1};
const event = (id: string, heardAt: number) => ({packetHash:"hash",packet:{payloadType:5,routeType:1},observation:{observerId:`obs-${id}`,heardAt}} as WsPacketObservation["data"]);
describe("Public topology chatter", () => {
it("joins either arrival order, anchors to the earliest mapped reception, deduplicates and expires", () => {
const chatter = new LiveChatter();
chatter.observe(event("b",200),graph,1_000);
chatter.message(message,1_100);
chatter.observe(event("a",100),graph,1_200);
expect(chatter.visible(1_300)).toEqual([]);
expect(chatter.visible(1_700)).toMatchObject([{sender:"Alice",content:"hello",nodeId:"a"}]);
chatter.message(message,1_800);
expect(chatter.visible(1_900)).toHaveLength(1);
expect(chatter.visible(1_000+CHAT_MS)).toEqual([]);
});
it("does not trust colliding channel hashes, unknown keys, sender identities or ambiguous relays", () => {
const chatter = new LiveChatter();
chatter.message({...message,isPublic:false},100);
chatter.observe(event("a",1),graph,100);
expect(chatter.visible(800)).toEqual([]);
chatter.clear();chatter.message(message,100);
const unknown=event("missing",1);unknown.observation.resolvedPath=[{confidence:"ambiguous",nodes:[{id:"a",publicKey:"a"}]}];
chatter.observe(unknown,graph,100);
expect(chatter.visible(800)).toEqual([]);
unknown.observation.resolvedPath[0]!.confidence="high";
chatter.observe(unknown,graph,100);
expect(chatter.visible(800)[0]?.nodeId).toBe("a");
});
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from "vitest";
import { buildTopology, reportFromEvent, LiveTraffic, project, fitCamera, panCamera, pathControls, visibleLinks, DEFAULT_CAMERA, linkContext, loadTopology, loadTopologyRoutes, NODE_CAP, FLOW_CAP, REPORT_CAP } from "../../../src/features/topology/topology";
import { getTopologyLinks, getNodesPage } from "../../../src/api/client";
import type { NodeSummary } from "../../../src/features/nodes/types";
import type { WsPacketObservation } from "../../../src/types/ws";
import type { KnownRoute, ResolvedHop } from "../../../src/types/api";
vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getTopologyLinks: vi.fn() }));
const node = (id: string, region = "YOW", scope = "#on"): NodeSummary => ({ id, publicKey: id.repeat(64), name: id, nodeType: 2, nodeTypeName: "repeater", lat: null, lng: null, iatas: [{ iata: region, lastHeard: 1 }], knownNeighborCount: 1, neighborIds: [], defaultScope: scope });
const a = { ...node("a"), neighborIds: ["b", "b", "a", "missing"] }, b = { ...node("b", "YKF"), neighborIds: ["a"] }, c = node("c");
const graph = buildTopology([a, b, c]);
const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, publicKey: id }] });
const event = (path: ResolvedHop[] = [hop("a"), hop("b"), hop("c")]): WsPacketObservation["data"] => ({ packetHash: "hash", packet: { payloadType: 5, payloadTypeName: "GRP_TXT", routeType: 1, routeTypeName: "FLOOD", isFirstObservation: true, observationCount: 1, scope: "#on" }, observation: { observerId: "obs", observerName: "Observer", iata: "YOW", heardAt: 1, rssi: -90, snr: 5, sourceBroker: "fixture", resolvedPath: path } });
describe("3D topology evidence and bounds", () => {
it("groups nodes within the selected shared regions despite a newer outside reception", () => {
const multi = { ...a, iatas: [{ iata: "YYZ", lastHeard: 30 }, { iata: "YOW", lastHeard: 20 }, { iata: "YKF", lastHeard: 10 }] };
expect(buildTopology([multi], ["YOW", "YKF"]).nodes[0]!.region).toBe("YOW");
expect(buildTopology([multi], ["YKF"]).nodes[0]!.region).toBe("YKF");
expect(buildTopology([multi]).nodes[0]!.region).toBe("YYZ");
});
it("keeps unlocated identities and only supplied neighbour edges, with stable positions", () => {
expect(graph.nodes).toHaveLength(3);
expect(graph.links).toEqual([["a", "b"]]);
expect(buildTopology([c, b, a]).nodes).toEqual(graph.nodes);
expect(graph.nodes.every(n => n.y >= 0 && n.y <= 28)).toBe(true);
expect(linkContext(graph.byId.get("a")!, graph.byId.get("b")!)).toEqual({ crossRegion: true, sharedDefault: "#on" });
expect(linkContext(graph.byId.get("a")!, { ...graph.byId.get("b")!, defaultScope: "#ON" }).sharedDefault).toBeNull();
});
it("does not bridge ambiguous, unloaded, or missing hops, or append reporting observers", () => {
expect(reportFromEvent(event(), graph, 100)?.segments).toEqual([["a", "b"], ["b", "c"]]);
for (const middle of [{ confidence: "ambiguous", nodes: [hop("b").nodes[0]!, hop("c").nodes[0]!] } as ResolvedHop, hop("missing"), { confidence: "none", nodes: [] } as ResolvedHop]) {
const report = reportFromEvent(event([hop("a"), middle, hop("c")]), graph, 100)!;
expect(report.segments).toEqual([]); expect(report.partial).toBe(true);
}
const missing = event(); delete missing.observation.resolvedPath;
expect(reportFromEvent(missing, graph, 100)?.segments).toEqual([]);
const repeat = event(); repeat.packet.isRepeat = true;
expect(reportFromEvent(repeat, graph, 100)).toBeNull();
const unknown = event(); unknown.packet.payloadType = 99;
expect(reportFromEvent(unknown, graph, 100)?.kind).toBe("other");
});
it("deduplicates packet/observer pairs in the live window and bounds report/animation memory", () => {
const traffic = new LiveTraffic();
const report = reportFromEvent(event(), graph, 100)!;
expect(traffic.add(report)).toBe(true); expect(traffic.add({ ...report, at: 200 })).toBe(false);
expect(traffic.add({ ...report, key: "hash|other", observerId: "other", at: 200 })).toBe(true);
for (let i = 0; i < REPORT_CAP + 10; i++) traffic.add({ ...report, key: `event${i}`, at: 300 });
expect(traffic.reports).toHaveLength(REPORT_CAP); expect(traffic.flows).toHaveLength(FLOW_CAP); expect(traffic.cappedUntil).toBeGreaterThan(300);
expect(traffic.trails.size).toBe(2);
traffic.prune(5_000); expect(traffic.flows).toEqual([]); expect(traffic.trails.size).toBe(2);
traffic.prune(60_301); expect(traffic.reports).toEqual([]); expect(traffic.flows).toEqual([]);
expect(traffic.trails.size).toBe(0);
expect(traffic.add({ ...report, at: 60_302 })).toBe(true);
});
it("uses depth perspective and a real orbit without invalid coordinates", () => {
const point = { x: 100, y: 20, z: 50 };
const front = project(point, DEFAULT_CAMERA, 1000, 700, 500);
const side = project(point, { ...DEFAULT_CAMERA, yaw: 1 }, 1000, 700, 500);
expect(front.x).not.toBe(side.x); expect(front.depth).not.toBe(side.depth);
expect([front.x, front.y, front.scale, side.x, side.y].every(Number.isFinite)).toBe(true);
});
it("looks down from above: raised nodes are closer and above their ground position", () => {
const point = { x: 50, y: 0, z: 0 };
const ground = project(point, DEFAULT_CAMERA, 1000, 700, 500);
const raised = project({ ...point, y: 70 }, DEFAULT_CAMERA, 1000, 700, 500);
expect(raised.depth).toBeLessThan(ground.depth);
expect(raised.scale).toBeGreaterThan(ground.scale);
expect(raised.y).toBeLessThan(ground.y);
const top = { ...DEFAULT_CAMERA, pitch: Math.PI / 2 };
expect(project({ ...point, y: 70 }, top, 1000, 700, 500).scale).toBeGreaterThan(project(point, top, 1000, 700, 500).scale);
});
it("stops on repeating cursors, passes cancellation, and bounds the expanded snapshot", async () => {
vi.mocked(getNodesPage).mockResolvedValue({ items: [a], nextCursor: 1, hasMore: true });
const controller = new AbortController();
const result = await loadTopology(undefined, controller.signal);
expect(result).toEqual({ nodes: [a], capped: true }); expect(getNodesPage).toHaveBeenCalledTimes(2);
vi.mocked(getNodesPage).mockClear().mockImplementation(async (_iatas, params) => ({ items: [a], nextCursor: (params?.cursor ?? 0) + 1, hasMore: true }));
await loadTopology(["YOW"], controller.signal); expect(getNodesPage).toHaveBeenCalledTimes(NODE_CAP / 200);
controller.abort(); await expect(loadTopology(undefined, controller.signal)).rejects.toThrow();
expect(getNodesPage).toHaveBeenCalledTimes(NODE_CAP / 200);
});
it("moves linked regions closer while keeping their islands apart", () => {
const input = Array.from({ length: 9 }, (_, i) => node(String(i), `R${i}`));
const before = buildTopology(input), after = buildTopology(input.map(n => n.id === "0" ? { ...n, neighborIds: ["8"] } : n));
const distance = (g: typeof graph) => Math.hypot(g.regions[0]!.x - g.regions[8]!.x, g.regions[0]!.z - g.regions[8]!.z);
expect(distance(after)).toBeLessThan(distance(before) * 0.7);
for (let i = 0; i < after.regions.length; i++) for (let j = 0; j < i; j++) {
const a = after.regions[i]!, b = after.regions[j]!;
expect(Math.hypot(a.x - b.x, a.z - b.z)).toBeGreaterThan(a.radius + b.radius + 40);
}
});
it("keeps more than 2,000 nodes and every supplied connection visible in All paths", () => {
const input = Array.from({ length: 2200 }, (_, i) => ({ ...node(String(i), `R${i % 11}`), neighborIds: Array.from({ length: 3 }, (_, j) => String((i + j + 1) % 2200)) }));
const expanded = buildTopology(input);
expect(expanded.nodes).toHaveLength(2200); expect(expanded.links).toHaveLength(6600); expect(expanded.linksCapped).toBe(false);
expect(visibleLinks(expanded, "all", "R0", "0")).toHaveLength(6600);
const bundled = buildTopology([a, b, c]); expect(bundled.bundles).toEqual([{ from: "YKF", to: "YOW", count: 1 }]);
expect(visibleLinks(bundled, "selected", "", "a")).toEqual([["a", "b"]]);
});
it("fits distant regions in portrait and landscape and pans around the fitted target", () => {
const points = [{ x: 3000, y: 0, z: -120 }, { x: 3300, y: 40, z: 140 }];
for (const [width, height] of [[390, 450], [1200, 600]]) {
const camera = fitCamera(points, DEFAULT_CAMERA, width!, height!, 4000);
for (const point of points) { const q = project(point, camera, width!, height!, 4000); expect(q.x).toBeGreaterThanOrEqual(49); expect(q.x).toBeLessThanOrEqual(width! - 49); expect(q.y).toBeGreaterThanOrEqual(49); expect(q.y).toBeLessThanOrEqual(height! - 49); }
const q = project(camera.target, camera, width!, height!, 4000); expect(q.x).toBe(width! / 2); expect(q.y).toBe(height! / 2);
const moved = project(camera.target, panCamera(camera, 30, 20, width!, height!, 4000), width!, height!, 4000);
expect(moved.x).toBeGreaterThan(q.x); expect(moved.y).toBeGreaterThan(q.y);
}
const curve = pathControls(points[0]!, points[1]!, true); expect(curve[0]).toEqual(points[0]); expect(curve[3]).toEqual(points[1]); expect(curve[1].y).toBeGreaterThan(100);
});
it("adds adjacent saved-route segments without bridging missing identities", async () => {
const route = (id: number, ids: string[], lastSeen: number): KnownRoute => ({ id, iata: "YOW", hopCount: ids.length, hops: ids.map(nodeId => ({ nodeId, hashBytes: "aa" })), firstSeen: 0, lastSeen, observationCount: 1 });
const routes = [route(1, ["b", "c"], 100_000_000), route(2, ["a", "missing", "c"], 100_000_000)];
const withRoutes = buildTopology([a, b, c], "", routes);
expect(withRoutes.links).toEqual([["a", "b"], ["b", "c"]]); expect([...withRoutes.routeLinks]).toEqual(["b|c"]);
vi.mocked(getTopologyLinks).mockResolvedValue({ links:[["b","c"]],capped:false,since:0,until:1 });
const controller = new AbortController();
expect(await loadTopologyRoutes(["YOW"],controller.signal)).toEqual({routes:[["b","c"]],capped:false});
expect(getTopologyLinks).toHaveBeenCalledTimes(1);
expect(getTopologyLinks).toHaveBeenCalledWith(["YOW"],"15m",controller.signal);
controller.abort(); await expect(loadTopologyRoutes(undefined, controller.signal)).rejects.toThrow();
});
it("isolates multi-region nodes into the requested region", () => {
const multi = { ...a, iatas: [{ iata: "YOW", lastHeard: 1 }, { iata: "YKF", lastHeard: 100 }] };
expect(buildTopology([multi], "YOW").nodes[0]!.region).toBe("YOW");
});
it("loads a complete 24h snapshot in one cancellable request and preserves its cap warning", async () => {
vi.mocked(getTopologyLinks).mockClear().mockResolvedValue({links:[["a","b"]],capped:true,since:0,until:1});
const signal=new AbortController().signal;
expect(await loadTopologyRoutes(undefined,signal,86_400_000)).toEqual({routes:[["a","b"]],capped:true});
expect(getTopologyLinks).toHaveBeenCalledExactlyOnceWith(undefined,"24h",signal);
});
it("does not scan global routes for an empty region", async () => {
vi.mocked(getTopologyLinks).mockClear();
expect(await loadTopologyRoutes([], new AbortController().signal)).toEqual({ routes: [], capped: false });
expect(getTopologyLinks).not.toHaveBeenCalled();
});
});
+20
View File
@@ -231,3 +231,23 @@ it("stops waiting once the region list loads without the selected region", async
await waitFor(() => expect(mockGetTraces).toHaveBeenCalled());
});
it("hides suspect and ambiguous tags by default, with an explicit evidence toggle", async () => {
mockGetTraces.mockResolvedValue([
tag("11111111",1,{traceType:"PING", quality:{status:"supported",reasons:[]}}),
tag("a0595008",2,{quality:{status:"suspect",reasons:["unsupported_version","too_many_hops"]}}),
tag("33333333",1,{quality:{status:"ambiguous",reasons:["ambiguous_prefix"]}}),
]);
renderTraces();
expect(await screen.findByText("11111111")).toBeInTheDocument();
expect(screen.queryByText("A0595008")).not.toBeInTheDocument();
expect(screen.queryByText("33333333")).not.toBeInTheDocument();
expect(screen.getByText(/2 hidden in loaded sample/)).toBeInTheDocument();
const toggle=screen.getByRole("checkbox",{name:"Show possible collision related observations"});
fireEvent.click(toggle);
expect(screen.getByText("A0595008")).toBeInTheDocument();
expect(screen.getByText("Suspect observation")).toHaveAttribute("class");
expect(screen.getByText("Possible prefix collision")).toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.queryByText("A0595008")).not.toBeInTheDocument();
});
+2 -2
View File
@@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => {
it("excludes named tabs case-insensitively", () => {
expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([
"Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas",
"Packets", "Channels", "Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas",
]);
expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([
"Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas",
"Packets", "Channels", "Observers", "Topology", "Routes", "Traces", "Analytics", "MyAtlas",
]);
});