Batch route search into two requests and gate old web versions (#152)

This commit is contained in:
MrAlders0n
2026-10-09 22:20:23 -04:00
committed by GitHub
parent ec1efebbc5
commit 8ac3e1bd4a
20 changed files with 559 additions and 139 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.0.2",
"version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.0.2",
"version": "2.0.3",
"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.0.2",
"version": "2.0.3",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+2
View File
@@ -16,6 +16,7 @@ import { ThemeProvider } from "./hooks/useTheme";
import { useIsMobile } from "./hooks/useMediaQuery";
import { AppShell } from "./components/AppShell";
import { SplashScreen } from "./components/SplashScreen";
import { VersionGate } from "./components/VersionGate";
import { PacketList } from "./features/packets/PacketList";
import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer";
import { NodeTable } from "./features/nodes/NodeTable";
@@ -374,6 +375,7 @@ export function App() {
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SplashScreen />
<VersionGate />
<AppInner />
</ThemeProvider>
</QueryClientProvider>
+20 -9
View File
@@ -113,6 +113,17 @@ export function getPacketDetail(packetHash: string): Promise<PacketDetail> {
return request(`/packets/${packetHash}`);
}
// /info: the oldest client versions this server accepts (null = no requirement) and its own version.
export interface ServerInfo {
minAppVersion: string | null;
minWebVersion: string | null;
serverVersion: string;
}
export function getServerInfo(): Promise<ServerInfo> {
return request("/info");
}
export function getIatas(): Promise<IataCode[]> {
return request("/iatas");
}
@@ -215,21 +226,21 @@ export function getRouteEvidence(iata: string, pathKey: string, params: { range?
return request(`/routes/${encodeURIComponent(iata)}/${encodeURIComponent(pathKey)}/observations`, params, signal);
}
// Search known routes for a path between two node hash prefixes within a single IATA. All three params
// are required by the server.
export function searchKnownRoutes(iata: string, from: string, to: string): Promise<KnownRoute[]> {
return request("/routes/search", { iata, from, to });
// Route search between two node hash prefixes. iatas narrows the search; omitted means every IATA.
// /routes/search returns routes within one IATA, /routes/cross routes that cross between two.
const iataList = (iatas: string[] | undefined) => (iatas?.length ? iatas.join(",") : undefined);
export function searchKnownRoutes(iatas: string[] | undefined, from: string, to: string, signal?: AbortSignal): Promise<KnownRoute[]> {
return request("/routes/search", { iatas: iataList(iatas), from, to }, signal);
}
// Search routes that cross IATA boundaries, from a hash in one IATA to a hash in another. All four
// params are required by the server.
export function searchCrossIATARoutes(
iatas: string[] | undefined,
fromHash: string,
fromIata: string,
toHash: string,
toIata: string,
signal?: AbortSignal,
): Promise<CrossIATARoute[]> {
return request("/routes/cross", { fromHash, fromIata, toHash, toIata });
return request("/routes/cross", { iatas: iataList(iatas), fromHash, toHash }, signal);
}
// Trace tags. /traces returns a bare array of per-tag summaries (ordered newest-heard first, cursor is
+44
View File
@@ -0,0 +1,44 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getServerInfo } from "../api/client";
import { isBelowVersion } from "../lib/version";
import { BeaconLogo } from "./BeaconLogo";
// Blocks the app when the server needs a newer Beacon Web. Any failure to read /info lets it through.
export function VersionGate({ currentVersion = __APP_VERSION__ }: { currentVersion?: string }) {
const { t } = useTranslation();
const { data } = useQuery({
queryKey: ["server-info"],
queryFn: getServerInfo,
retry: false,
staleTime: 60_000, // also throttles the refetch on focus
refetchOnWindowFocus: true,
});
const min = data?.minWebVersion;
if (!isBelowVersion(currentVersion, min)) return null;
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="version-gate-message"
className="fixed inset-0 z-[10000] flex items-center justify-center bg-bg-base px-4"
>
<div className="flex flex-col items-center gap-5 max-w-md text-center">
<BeaconLogo size={64} className="text-primary" />
<p id="version-gate-message" className="font-mono text-sm text-text-normal leading-relaxed">
{t("versionGate.message", { min, current: currentVersion })}
</p>
<button
type="button"
autoFocus
onClick={() => window.location.reload()}
className="h-8 text-xs font-mono px-4 rounded-sm border border-primary-dim bg-primary/6 text-primary hover:bg-primary/15 cursor-pointer transition-colors"
>
{t("versionGate.reload")}
</button>
</div>
</div>
);
}
+171 -87
View File
@@ -14,8 +14,7 @@ import { MultiSelectDropdown } from "../../components/MultiSelectDropdown";
import { useIsMobile } from "../../hooks/useMediaQuery";
import { RouteDetailPanel, type RouteActions } from "./RouteDetailPanel";
import { ResolvedHopBlock } from "../packets/PathData";
import { formatHex } from "../../lib/formatters";
import type { KnownRoute, CrossIATARoute, ResolvedHop, ResolvedNode, RouteHop } from "../../types/api";
import type { KnownRoute, CrossIATARoute, ResolvedHop, RouteHop } from "../../types/api";
const inputClass =
"text-[11px] font-mono bg-bg-surface border border-border rounded-sm px-2 py-1 text-text-bright " +
@@ -24,8 +23,6 @@ const inputClass =
// stable id accessor for the paginator's dedup (module-level so the memo isn't rebuilt each render)
const routeId = (r: KnownRoute) => String(r.id);
const nodeLabel = (n: ResolvedNode) => n.name ?? formatHex(n.publicKey);
// A run of route hops as a hash chain (reusing the packet path renderer); hops are high-confidence.
function HopChain({ hops }: { hops: RouteHop[] }) {
return (
@@ -44,41 +41,14 @@ function HopChain({ hops }: { hops: RouteHop[] }) {
}
// Memoized so the 10s <Timestamp> ticks in sibling columns don't re-reconcile the chain and its popovers.
const RouteHopChain = memo(function RouteHopChain({ route }: { route: KnownRoute }) {
const RouteHopChain = memo(function RouteHopChain({ hops }: { hops: RouteHop[] }) {
return (
<div className="flex flex-wrap items-center gap-1 font-mono text-[13px]">
<HopChain hops={route.hops} />
<HopChain hops={hops} />
</div>
);
});
// A cross-IATA route: source segment → boundary hop (the two nodes that bridge the IATAs) → target segment.
function CrossRouteCard({ route }: { route: CrossIATARoute }) {
const { t } = useTranslation();
const { crossHop } = route;
return (
<div className="bg-bg-base border border-border rounded px-3 py-2 flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Badge variant="default">{crossHop.fromIata}</Badge>
<span className="text-text-dim" aria-hidden>→</span>
<Badge variant="default">{crossHop.toIata}</Badge>
</div>
<span className="font-mono text-[11px] text-text-dim">{t("routes.hops", { count: route.totalHops })}</span>
</div>
<div className="flex flex-wrap items-center gap-1 font-mono text-[13px]">
<HopChain hops={route.sourceSegment} />
{route.sourceSegment.length > 0 && <span className="text-warn" aria-hidden>⇒</span>}
<span className="text-primary font-semibold">{nodeLabel(crossHop.fromNode)}</span>
<span className="text-warn" aria-hidden>⇒</span>
<span className="text-primary font-semibold">{nodeLabel(crossHop.toNode)}</span>
{route.targetSegment.length > 0 && <span className="text-warn" aria-hidden>⇒</span>}
<HopChain hops={route.targetSegment} />
</div>
</div>
);
}
// ids keep sorting stable when the headers are translated
const buildColumns = (t: TFunction): Column<KnownRoute>[] => [
{
@@ -96,7 +66,7 @@ const buildColumns = (t: TFunction): Column<KnownRoute>[] => [
{
id: "route",
header: t("routes.columns.route"),
cell: (r) => <RouteHopChain route={r} />,
cell: (r) => <RouteHopChain hops={r.hops} />,
},
{
id: "obs",
@@ -129,7 +99,7 @@ function RouteCard({ route: r }: { route: KnownRoute }) {
<Badge variant="default">{r.iata}</Badge>
<span className="font-mono text-[11px] text-text-dim">{t("routes.hops", { count: r.hopCount })} · {t("routes.obs", { count: r.observationCount, value: r.observationCount.toLocaleString() })}</span>
</div>
<RouteHopChain route={r} />
<RouteHopChain hops={r.hops} />
<div className="flex items-center gap-2 font-mono text-[11px] text-text-muted">
<span>{t("routes.first")} <Timestamp value={r.firstSeen} /></span>
<span aria-hidden>·</span>
@@ -139,25 +109,111 @@ function RouteCard({ route: r }: { route: KnownRoute }) {
);
}
interface SearchParams {
from: string;
to: string;
iatas: string[];
// A search result: a known route within one IATA, or a cross-IATA route flattened to the same shape.
interface SearchRow {
key: string;
area: string;
hops: RouteHop[];
hopCount: number;
lastSeen: number;
route?: KnownRoute;
}
// every directed (a,b), a≠b pair of the selected IATAs — we don't know which IATA holds the source
// vs dest hash, so we try all directions and flatten.
function directedPairs(iatas: string[]): [string, string][] {
const pairs: [string, string][] = [];
for (const a of iatas) for (const b of iatas) if (a !== b) pairs.push([a, b]);
return pairs;
const CROSS_KEY = "cross:";
const MAX_SEARCH_IATAS = 100;
const HASH_RE = /^(?:[0-9a-f]{2}){1,4}$/;
const knownRow = (r: KnownRoute): SearchRow =>
({ key: String(r.id), area: r.iata, hops: r.hops, hopCount: r.hopCount, lastSeen: r.lastSeen, route: r });
const crossRow = (r: CrossIATARoute, i: number): SearchRow => ({
key: `${CROSS_KEY}${i}`,
area: `${r.crossHop.fromIata} → ${r.crossHop.toIata}`,
hops: [...r.sourceSegment, ...r.targetSegment],
hopCount: r.totalHops,
lastSeen: r.crossHop.lastSeen,
});
// Picked IATAs, else the region's, else undefined (global). Picking every area is the global search too.
function searchScope(picked: string[], regionIatas: string[] | undefined, allIatas: string[]): string[] | undefined {
const dedupe = (codes: string[]) => [...new Set(codes.map((c) => c.toUpperCase()))].sort();
if (picked.length) {
const scope = dedupe(picked);
const all = new Set(allIatas);
return all.size > 0 && [...all].every((c) => scope.includes(c)) ? undefined : scope;
}
return regionIatas && dedupe(regionIatas);
}
// 400s carry a message meant for the user (bad hash, too many areas), so show it as-is.
function searchErrorText(err: unknown, t: TFunction): string {
const status = (err as { status?: number }).status;
if (status === 400 && err instanceof Error) return err.message;
if (status === 503) return t("routes.timeout");
return t("routes.searchFailed");
}
const buildSearchColumns = (t: TFunction): Column<SearchRow>[] => [
{
id: "area",
header: t("routes.columns.area"),
sortValue: (r) => r.area,
cell: (r) => <Badge variant="default">{r.area}</Badge>,
},
{
id: "hops",
header: t("routes.columns.hops"),
sortValue: (r) => r.hopCount,
cell: (r) => r.hopCount,
},
{
id: "route",
header: t("routes.columns.route"),
cell: (r) => <RouteHopChain hops={r.hops} />,
},
{
id: "obs",
header: t("routes.columns.obs"),
className: "text-text-muted",
sortValue: (r) => r.route?.observationCount,
cell: (r) => r.route?.observationCount.toLocaleString(),
},
{
id: "lastSeen",
header: t("routes.columns.lastSeen"),
className: "text-text-muted",
sortValue: (r) => r.lastSeen,
cell: (r) => <Timestamp value={r.lastSeen} />,
},
];
function SearchRowCard({ row }: { row: SearchRow }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Badge variant="default">{row.area}</Badge>
<span className="font-mono text-[11px] text-text-dim">
{t("routes.hops", { count: row.hopCount })}
{row.route && <> · {t("routes.obs", { count: row.route.observationCount, value: row.route.observationCount.toLocaleString() })}</>}
</span>
</div>
<RouteHopChain hops={row.hops} />
<div className="font-mono text-[11px] text-text-muted">
{t("routes.last")} <Timestamp value={row.lastSeen} />
</div>
</div>
);
}
const renderSearchCard = (r: SearchRow) => <SearchRowCard row={r} />;
const renderRouteCard = (r: KnownRoute) => <RouteCard route={r} />;
export function RouteTable(actions: RouteActions) {
const { t } = useTranslation();
const columns = useMemo(() => buildColumns(t), [t]);
const searchColumns = useMemo(() => buildSearchColumns(t), [t]);
const { iatas, isResolved } = useRegion();
const regionPending = isResolved === false;
const { selection } = useRegionSelection();
@@ -182,13 +238,12 @@ export function RouteTable(actions: RouteActions) {
}
}, [selection, closeRoute]);
// path search form: source→dest hashes, scoped to a multi-select of IATAs. One IATA → within-IATA
// /routes/search; two+ → /routes/cross across the directed pairs. Hashes + ≥1 IATA required.
// path search form: source→dest hashes, optionally narrowed to picked IATAs (else the region's).
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [searchIatas, setSearchIatas] = useState<string[]>([]);
const [search, setSearch] = useState<SearchParams | null>(null);
const isCross = search != null && search.iatas.length >= 2;
const [search, setSearch] = useState<{ from: string; to: string; iatas: string[] | undefined } | null>(null);
const [hashError, setHashError] = useState(false);
// /routes filters by a single IATA only, so when the region resolves to exactly one IATA push the
// filter to the server for true server-side paging; otherwise page unfiltered and filter the region
@@ -207,22 +262,22 @@ export function RouteTable(actions: RouteActions) {
enabled: !regionPending,
});
const { data: searchRoutes, isLoading: searchLoading } = useQuery({
queryKey: ["routes-search", search?.iatas[0], search?.from, search?.to],
queryFn: () => searchKnownRoutes(search!.iatas[0]!, search!.from, search!.to),
enabled: search !== null && !isCross,
// Two calls per search: routes within an IATA and routes crossing two. A one-IATA scope can't cross.
// No retries: a 503 is the server's 15s search timeout, and retrying it just repeats that work.
const scopeKey = search?.iatas?.join(",") ?? "*";
const isCross = search != null && search.iatas?.length !== 1;
const knownQuery = useQuery({
queryKey: ["routes-search", scopeKey, search?.from, search?.to],
queryFn: ({ signal }) => searchKnownRoutes(search!.iatas, search!.from, search!.to, signal),
enabled: search !== null,
retry: false,
staleTime: 60_000,
});
const { data: crossRoutes, isLoading: crossLoading } = useQuery({
queryKey: ["routes-cross", [...(search?.iatas ?? [])].sort().join(","), search?.from, search?.to],
queryFn: async () => {
const results = await Promise.all(
directedPairs(search!.iatas).map(([a, b]) => searchCrossIATARoutes(search!.from, a, search!.to, b)),
);
return results.flat();
},
enabled: search !== null && isCross,
const crossQuery = useQuery({
queryKey: ["routes-cross", scopeKey, search?.from, search?.to],
queryFn: ({ signal }) => searchCrossIATARoutes(search!.iatas, search!.from, search!.to, signal),
enabled: isCross,
retry: false,
staleTime: 60_000,
});
@@ -233,6 +288,7 @@ export function RouteTable(actions: RouteActions) {
queryFn: getIatas,
staleTime: 5 * 60_000,
});
const allIatas = useMemo(() => (iataCodes ?? []).map((i) => i.iata), [iataCodes]);
const iataOptions = useMemo(
() => (iataCodes ?? []).map((i) => ({ value: i.iata, label: i.displayName ? `${i.iata} — ${i.displayName}` : i.iata })),
[iataCodes],
@@ -241,12 +297,23 @@ export function RouteTable(actions: RouteActions) {
// searching shows the server's matches as-is; otherwise show the region-filtered full list (empty
// region = all). Filtering by IATA stays client-side, consistent with the other tabs.
const rows = useMemo(() => {
if (search) return isCross ? [] : searchRoutes;
if (search) return knownQuery.data;
if (regionPending) return [];
if (!iatas) return listRoutes;
const set = new Set(iatas);
return listRoutes.filter((r) => set.has(r.iata));
}, [search, isCross, searchRoutes, listRoutes, iatas, regionPending]);
}, [search, knownQuery.data, listRoutes, iatas, regionPending]);
const searchLoading = knownQuery.isLoading || (isCross && crossQuery.isLoading);
// presorted so the table's stable hop-count sort breaks ties newest-first
const searchRows = useMemo(() => {
if (!search || searchLoading) return undefined;
return [...(knownQuery.data ?? []).map(knownRow), ...(isCross ? crossQuery.data ?? [] : []).map(crossRow)]
.sort((a, b) => a.hopCount - b.hopCount || b.lastSeen - a.lastSeen);
}, [search, searchLoading, isCross, knownQuery.data, crossQuery.data]);
const searchErrors = [...new Set(
[knownQuery.error, isCross ? crossQuery.error : null].filter(Boolean).map((e) => searchErrorText(e, t)),
)];
const selectedRoute = useMemo(
() => rows?.find((r) => String(r.id) === selectedKey),
@@ -255,6 +322,7 @@ export function RouteTable(actions: RouteActions) {
const selectRoute = (id: string | null) => {
if (id === null) { closeRoute(); return; }
if (id.startsWith(CROSS_KEY)) return; // cross-IATA routes have no detail view
const route = rows?.find(row => String(row.id) === id);
if (route?.pathKey) {
setSelectedKey(null);
@@ -277,13 +345,19 @@ export function RouteTable(actions: RouteActions) {
const isMobile = useIsMobile();
const panelOpen = Boolean(pathKey && routeIata || selectedRoute);
const canSearch = !!(from.trim() && to.trim() && searchIatas.length >= 1);
const scope = useMemo(() => searchScope(searchIatas, iatas, allIatas), [searchIatas, iatas, allIatas]);
const tooManyAreas = (scope?.length ?? 0) > MAX_SEARCH_IATAS;
const emptyRegion = scope?.length === 0;
const canSearch = !!(from.trim() && to.trim()) && !regionPending && !tooManyAreas && !emptyRegion;
// clear any selection when the visible list changes out from under it (search submit/clear)
const submitSearch = useCallback(() => {
if (!from.trim() || !to.trim() || searchIatas.length < 1) return;
setSearch({ from: from.trim(), to: to.trim(), iatas: searchIatas });
if (!canSearch) return;
const fromHash = from.trim().toLowerCase(), toHash = to.trim().toLowerCase();
if (!HASH_RE.test(fromHash) || !HASH_RE.test(toHash)) { setHashError(true); return; }
setHashError(false);
setSearch({ from: fromHash, to: toHash, iatas: scope });
closeRoute();
}, [from, to, searchIatas, closeRoute]);
}, [canSearch, from, to, scope, closeRoute]);
const clearSearch = useCallback(() => {
setSearch(null);
closeRoute();
@@ -304,7 +378,7 @@ export function RouteTable(actions: RouteActions) {
placeholder={t("routes.fromPlaceholder")}
aria-label={t("routes.fromLabel")}
value={from}
onChange={(e) => setFrom(e.target.value)}
onChange={(e) => { setFrom(e.target.value); setHashError(false); }}
onKeyDown={onKeyDown}
/>
<span className="text-text-dim text-xs shrink-0" aria-hidden>→</span>
@@ -313,7 +387,7 @@ export function RouteTable(actions: RouteActions) {
placeholder={t("routes.toPlaceholder")}
aria-label={t("routes.toLabel")}
value={to}
onChange={(e) => setTo(e.target.value)}
onChange={(e) => { setTo(e.target.value); setHashError(false); }}
onKeyDown={onKeyDown}
/>
</div>
@@ -346,36 +420,46 @@ export function RouteTable(actions: RouteActions) {
</button>
)}
</div>
{(hashError || tooManyAreas) && (
<span className="font-mono text-[11px] text-warn">{t(hashError ? "routes.badHash" : "routes.tooManyAreas")}</span>
)}
</div>
{search && searchErrors.length > 0 && (searchRows?.length ?? 0) > 0 && (
<div role="alert" className="px-4 py-1.5 border-b border-border-subtle font-mono text-[11px] text-danger">
{searchErrors.join(" · ")}
</div>
)}
<div className="flex flex-1 min-h-0">
{isCross ? (
<div className="flex-1 min-w-0 overflow-y-auto p-3 flex flex-col gap-2">
{crossLoading ? (
<div className="font-mono text-[13px] text-text-dim">{t("routes.searching")}</div>
) : crossRoutes && crossRoutes.length > 0 ? (
crossRoutes.map((r, i) => <CrossRouteCard key={i} route={r} />)
) : (
<div className="font-mono text-[13px] text-text-dim">{t("routes.noCross")}</div>
)}
{search ? (
<div key="search" className={`flex-1 min-w-0 ${panelOpen ? "hidden md:flex" : "flex"} flex-col min-h-0`}>
<DataTable
columns={searchColumns}
rows={searchRows}
rowKey={(r) => r.key}
selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey}
onSelect={selectRoute}
isLoading={searchLoading}
emptyLabel={searchErrors.length > 0 ? searchErrors.join(" · ") : t("routes.noMatches")}
defaultSort={{ id: "hops", direction: "asc" }}
renderCard={renderSearchCard}
/>
</div>
) : (
<div className={`relative flex-1 min-w-0 ${panelOpen ? "hidden md:flex" : "flex"} flex-col min-h-0`}>
<div key="list" className={`relative flex-1 min-w-0 ${panelOpen ? "hidden md:flex" : "flex"} flex-col min-h-0`}>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => String(r.id)}
selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey}
onSelect={selectRoute}
isLoading={search ? searchLoading : listLoading || regionPending}
emptyLabel={t(search ? "routes.noMatches" : "routes.empty")}
isLoading={listLoading || regionPending}
emptyLabel={t("routes.empty")}
defaultSort={{ id: "lastSeen", direction: "desc" }}
onEndReached={search ? undefined : loadMore}
onEndReached={loadMore}
renderCard={renderRouteCard}
/>
{!search && (
<LoadingPill loading={isPaging} error={isError} count={loadedCount} noun="routes" position="bottom-3 right-3" />
)}
<LoadingPill loading={isPaging} error={isError} count={loadedCount} noun="routes" position="bottom-3 right-3" />
</div>
)}
{pathKey && routeIata ? (
+8 -1
View File
@@ -729,10 +729,17 @@
"search": "Search",
"clear": "Clear",
"searching": "Searching…",
"noCross": "No routes between these areas",
"badHash": "Hashes must be 2, 4, 6 or 8 hex characters",
"tooManyAreas": "Pick at most 100 areas",
"timeout": "Route search timed out — narrow the areas or use a longer hash.",
"searchFailed": "Route search failed",
"noMatches": "No matching routes",
"empty": "No routes"
},
"versionGate": {
"message": "This Beacon server needs Beacon Web {{min}} or newer (this is {{current}}). Reload the page; if this keeps appearing, the web container needs updating.",
"reload": "Reload"
},
"routeDetail": {
"title": "Route Detail",
"summary": "Summary",
+8 -1
View File
@@ -747,10 +747,17 @@
"search": "Rechercher",
"clear": "Effacer",
"searching": "Recherche…",
"noCross": "Aucun trajet entre ces zones",
"badHash": "Les hash doivent faire 2, 4, 6 ou 8 caractères hexadécimaux",
"tooManyAreas": "Choisissez au plus 100 zones",
"timeout": "La recherche de trajet a expiré — réduisez les zones ou utilisez un hash plus long.",
"searchFailed": "La recherche de trajet a échoué",
"noMatches": "Aucun trajet correspondant",
"empty": "Aucun trajet"
},
"versionGate": {
"message": "Ce serveur Beacon nécessite Beacon Web {{min}} ou plus récent (version actuelle : {{current}}). Rechargez la page ; si ce message persiste, le conteneur web doit être mis à jour.",
"reload": "Recharger"
},
"routeDetail": {
"title": "Détail du trajet",
"summary": "Résumé",
+8 -1
View File
@@ -729,10 +729,17 @@
"search": "Sök",
"clear": "Rensa",
"searching": "Söker…",
"noCross": "Inga rutter mellan dessa områden",
"badHash": "Hashar måste vara 2, 4, 6 eller 8 hextecken",
"tooManyAreas": "Välj högst 100 områden",
"timeout": "Ruttsökningen tog för lång tid — begränsa områdena eller använd en längre hash.",
"searchFailed": "Ruttsökningen misslyckades",
"noMatches": "Inga matchande rutter",
"empty": "Inga rutter"
},
"versionGate": {
"message": "Den här Beacon-servern kräver Beacon Web {{min}} eller senare (detta är {{current}}). Ladda om sidan; om detta fortsätter att visas behöver webbcontainern uppdateras.",
"reload": "Ladda om"
},
"routeDetail": {
"title": "Ruttdetaljer",
"summary": "Sammanfattning",
+13
View File
@@ -0,0 +1,13 @@
const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
// True when current is strictly older than min. Anything not plain X.Y.Z compares as "not below",
// so a bad value from the server can never lock users out.
export function isBelowVersion(current: string, min: string | null | undefined): boolean {
const a = VERSION_RE.exec(current), b = min ? VERSION_RE.exec(min) : null;
if (!a || !b) return false;
for (let i = 1; i <= 3; i++) {
const diff = Number(a[i]) - Number(b[i]);
if (diff !== 0) return diff < 0;
}
return false;
}
@@ -24,6 +24,7 @@ vi.mock("../src/api/ws-manager", () => {
return { WsManager };
});
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/api/client", () => ({
getRegions: async () => [],
getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }),
+1
View File
@@ -11,6 +11,7 @@ vi.mock("../src/api/ws-manager", () => ({ WsManager: class {
} }));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getBrokers: async () => [], getScopes: async () => [] }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => {
const { setSelection } = useRegionSelection();
return <><button onClick={() => onTabChange("Observers")}>Observer tab</button><button onClick={() => onTabChange("Routes")}>Route tab</button><button onClick={() => onTabChange("Analytics")}>Analytics tab</button><button onClick={() => setSelection({ regions: [], iatas: ["YVR"] })}>Change region</button>{children}</>;
+1
View File
@@ -24,6 +24,7 @@ vi.mock("../src/api/ws-manager", () => {
return { WsManager };
});
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/api/client", () => ({
getRegions: async () => [],
getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }),
+1
View File
@@ -7,6 +7,7 @@ import { ModalOverlay } from "../src/components/ModalOverlay";
vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } }));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children}</> }));
vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null }));
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <><button onClick={() => onViewObserver("observer")}>Node observer</button><button onClick={() => onAnalyzePacket("first")}>Node packet</button></> }));
+1
View File
@@ -16,6 +16,7 @@ vi.mock("../src/api/ws-manager", () => ({
}));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({
AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => (
<>
+1
View File
@@ -20,6 +20,7 @@ vi.mock("../src/lib/constants", async (importOriginal) => {
return { ...actual, ENABLED_TABS: actual.ENABLED_TABS.filter((tab) => tab !== "Map") };
});
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
vi.mock("../src/components/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({
AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => (
<>
+18 -10
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client";
import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client";
import type { Feature, Polygon } from "geojson";
import type { NodeSummary } from "../../src/features/nodes/types";
import type { ObserverSummary } from "../../src/features/observers/types";
@@ -198,17 +198,25 @@ describe("getKnownRoutesPage", () => {
});
});
describe("searchKnownRoutes", () => {
it("hits /routes/search with the required iata/from/to", async () => {
describe("route search", () => {
it("sends the IATA list to /routes/search", async () => {
const getUrl = mockFetchOnce([]);
await searchKnownRoutes(["YYC", "YVR"], "6d", "be");
const url = new URL(getUrl());
expect(url.pathname).toContain("/routes/search");
expect(url.searchParams.get("iatas")).toBe("YYC,YVR");
expect(url.searchParams.get("from")).toBe("6d");
expect(url.searchParams.get("to")).toBe("be");
});
await searchKnownRoutes("YYC", "6d", "be");
const url = getUrl();
expect(url).toContain("/routes/search");
expect(url).toContain("iata=YYC");
expect(url).toContain("from=6d");
expect(url).toContain("to=be");
it("omits iatas for a global /routes/cross search", async () => {
const getUrl = mockFetchOnce([]);
await searchCrossIATARoutes(undefined, "6d", "be");
const url = new URL(getUrl());
expect(url.pathname).toContain("/routes/cross");
expect(url.searchParams.has("iatas")).toBe(false);
expect(url.searchParams.get("fromHash")).toBe("6d");
expect(url.searchParams.get("toHash")).toBe("be");
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider, focusManager } from "@tanstack/react-query";
import { VersionGate } from "../../src/components/VersionGate";
import { getServerInfo } from "../../src/api/client";
vi.mock("../../src/api/client", () => ({ getServerInfo: vi.fn() }));
const mockGetServerInfo = vi.mocked(getServerInfo);
const info = (minWebVersion: string | null) => ({ minAppVersion: null, minWebVersion, serverVersion: "2.0.4" });
function renderGate(currentVersion = "2.0.2") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><VersionGate currentVersion={currentVersion} /></QueryClientProvider>);
}
const blocked = () => screen.queryByRole("alertdialog");
beforeEach(() => {
mockGetServerInfo.mockReset();
});
afterEach(() => {
vi.useRealTimers();
focusManager.setFocused(undefined);
});
describe("VersionGate", () => {
it("blocks a web version older than the server's minimum", async () => {
mockGetServerInfo.mockResolvedValue(info("2.0.3"));
const reload = vi.fn();
vi.stubGlobal("location", { ...window.location, reload });
renderGate("2.0.2");
expect(await screen.findByRole("alertdialog")).toHaveTextContent(/needs Beacon Web 2\.0\.3 or newer \(this is 2\.0\.2\)/);
fireEvent.click(screen.getByRole("button", { name: "Reload" }));
expect(reload).toHaveBeenCalled();
vi.unstubAllGlobals();
});
it.each([
["an equal version", info("2.0.3")],
["no minimum", info(null)],
["a malformed minimum", info("2.0")],
])("lets the app through with %s", async (_label, value) => {
mockGetServerInfo.mockResolvedValue(value);
renderGate("2.0.3");
await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
await act(async () => {});
expect(blocked()).toBeNull();
});
it("fails open when the server has no /info", async () => {
mockGetServerInfo.mockRejectedValue(Object.assign(new Error("not found"), { status: 404 }));
renderGate("2.0.2");
await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
await act(async () => {});
expect(blocked()).toBeNull();
});
it("fails open on a network error", async () => {
mockGetServerInfo.mockRejectedValue(new TypeError("Failed to fetch"));
renderGate("2.0.2");
await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalled());
await act(async () => {});
expect(blocked()).toBeNull();
});
it("re-checks when the tab regains focus, at most once a minute", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockGetServerInfo.mockResolvedValue(info(null));
renderGate("2.0.2");
await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalledTimes(1));
act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await act(async () => {});
expect(mockGetServerInfo).toHaveBeenCalledTimes(1);
mockGetServerInfo.mockResolvedValue(info("2.0.3"));
await act(async () => { vi.advanceTimersByTime(61_000); });
act(() => { focusManager.setFocused(false); focusManager.setFocused(true); });
await waitFor(() => expect(mockGetServerInfo).toHaveBeenCalledTimes(2));
expect(await screen.findByRole("alertdialog")).toBeInTheDocument();
});
});
+148 -27
View File
@@ -36,8 +36,7 @@ const mockGetRegions = vi.mocked(getRegions);
const node = (id: string, name: string) => ({ id, name, publicKey: "deadbeef" });
function renderTable(selection = ALL_REGIONS) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
function renderTable(selection = ALL_REGIONS, client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>
<MemoryRouter><RegionProvider defaultSelection={selection}>{children}</RegionProvider></MemoryRouter>
@@ -62,51 +61,173 @@ beforeEach(() => {
mockGetIatas.mockResolvedValue([
{ iata: "AAA", displayName: "Alpha" },
{ iata: "BBB", displayName: "Beta" },
{ iata: "CCC", displayName: "Gamma" },
]);
});
describe("RouteTable search", () => {
it("searches within a single IATA when exactly one IATA is selected", async () => {
const fillHashes = (from = "aa11", to = "bb22") => {
fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: from } });
fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: to } });
};
const known = (id: number, hopCount: number, lastSeen: number, iata = "AAA"): KnownRoute =>
({ id, iata, hopCount, hops: [], firstSeen: 1, lastSeen, observationCount: 40 + id });
const crossRoute = (totalHops: number, lastSeen: number): CrossIATARoute => ({
sourceSegment: [{ nodeId: "n1", hashBytes: "aa11", node: node("n1", "Src Node") }],
crossHop: { fromNode: node("n1", "Src Node"), toNode: node("n2", "Dst Node"), fromIata: "AAA", toIata: "BBB", lastSeen },
targetSegment: [{ nodeId: "n2", hashBytes: "bb22", node: node("n2", "Dst Node") }],
totalHops,
});
const apiError = (status: number, message: string) => Object.assign(new Error(message), { status, code: "x" });
it("searches only within the area when exactly one IATA is picked", async () => {
renderTable();
await screen.findByText("Find path");
fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: "aa11" } });
fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: "bb22" } });
fillHashes("AA11", "bb22");
openIataPicker();
checkIata("AAA");
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith("AAA", "aa11", "bb22"));
await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA"], "aa11", "bb22", expect.any(AbortSignal)));
expect(mockSearchCrossIATARoutes).not.toHaveBeenCalled();
});
it("searches cross-IATA when two IATAs are selected, rendering the segmented chain", async () => {
const cross: CrossIATARoute = {
sourceSegment: [{ nodeId: "n1", hashBytes: "aa11", node: node("n1", "Src Node") }],
crossHop: { fromNode: node("n1", "Src Node"), toNode: node("n2", "Dst Node"), fromIata: "AAA", toIata: "BBB", lastSeen: 2 },
targetSegment: [{ nodeId: "n2", hashBytes: "bb22", node: node("n2", "Dst Node") }],
totalHops: 3,
};
mockSearchCrossIATARoutes.mockImplementation((_f, fromIata, _t, toIata) =>
Promise.resolve(fromIata === "AAA" && toIata === "BBB" ? [cross] : []),
);
it("makes one call per endpoint for a multi-IATA pick, with the sorted list", async () => {
renderTable();
await screen.findByText("Find path");
fireEvent.change(screen.getByPlaceholderText("from hash"), { target: { value: "aa11" } });
fireEvent.change(screen.getByPlaceholderText("to hash"), { target: { value: "bb22" } });
fillHashes();
openIataPicker();
checkIata("AAA");
checkIata("BBB");
checkIata("AAA");
fireEvent.click(screen.getByText("Search"));
await waitFor(() =>
expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith("aa11", "AAA", "bb22", "BBB"),
);
await waitFor(() => expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal)));
expect(mockSearchCrossIATARoutes).toHaveBeenCalledTimes(1);
expect(mockSearchKnownRoutes).toHaveBeenCalledTimes(1);
expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal));
});
it("falls back to the region's IATAs when nothing is picked", async () => {
renderTable({ regions: [], iatas: ["BBB", "AAA"] });
await screen.findByText("Find path");
fillHashes();
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal)));
expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(["AAA", "BBB"], "aa11", "bb22", expect.any(AbortSignal));
});
it("searches globally with no region and nothing picked", async () => {
renderTable();
await screen.findByText("Find path");
fillHashes();
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal)));
expect(mockSearchCrossIATARoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal));
});
it("treats every area picked as global", async () => {
renderTable();
await screen.findByText("Find path");
fillHashes();
openIataPicker();
fireEvent.click(screen.getByText("All"));
fireEvent.click(screen.getByText("Search"));
await waitFor(() => expect(mockSearchKnownRoutes).toHaveBeenCalledWith(undefined, "aa11", "bb22", expect.any(AbortSignal)));
});
it("blocks a search over more than 100 areas", async () => {
const codes = Array.from({ length: 102 }, (_, i) => `X${String(i).padStart(3, "0")}`);
mockGetIatas.mockResolvedValue(codes.map((iata) => ({ iata })));
renderTable();
await screen.findByText("Find path");
fillHashes();
openIataPicker();
fireEvent.click(screen.getByText("All"));
checkIata(codes[0]!);
expect(screen.getByText("Pick at most 100 areas")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Search" })).toBeDisabled();
});
it("rejects a hash that isn't 1–4 bytes of hex", async () => {
renderTable();
await screen.findByText("Find path");
fillHashes("abc", "bb22");
fireEvent.click(screen.getByText("Search"));
expect(await screen.findByText("Hashes must be 2, 4, 6 or 8 hex characters")).toBeInTheDocument();
expect(mockSearchKnownRoutes).not.toHaveBeenCalled();
expect(await screen.findByText("Src Node")).toBeInTheDocument();
expect(screen.getByText("Dst Node")).toBeInTheDocument();
});
it("merges both result sets by hop count, then most recently seen", async () => {
mockSearchKnownRoutes.mockResolvedValue([known(1, 2, 10), known(2, 4, 50)]);
mockSearchCrossIATARoutes.mockResolvedValue([crossRoute(2, 30)]);
renderTable();
await screen.findByText("Find path");
fillHashes();
fireEvent.click(screen.getByText("Search"));
const crossLabel = await screen.findByText("AAA → BBB");
const rowsText = screen.getAllByRole("row").map((r) => r.textContent ?? "");
const order = ["AAA → BBB", "41", "42"].map((needle) => rowsText.findIndex((t) => t.includes(needle)));
expect(order).toEqual([...order].sort((a, b) => a - b));
expect(order[0]).toBeGreaterThan(-1);
const crossRow = crossLabel.closest("tr")!;
expect(crossRow.textContent).toContain("AA11");
expect(crossRow.textContent).toContain("BB22");
});
it("shows the server's 400 message as-is and never 'no matches'", async () => {
const msg = "hash matches too many areas; use a longer hash or fewer iatas";
mockSearchKnownRoutes.mockRejectedValue(apiError(400, msg));
mockSearchCrossIATARoutes.mockRejectedValue(apiError(400, msg));
renderTable();
await screen.findByText("Find path");
fillHashes("aa", "bb");
fireEvent.click(screen.getByText("Search"));
expect(await screen.findByText(msg)).toBeInTheDocument();
expect(screen.queryByText("No matching routes")).not.toBeInTheDocument();
});
it("shows the timeout message on a 503", async () => {
mockSearchKnownRoutes.mockRejectedValue(apiError(503, "timeout"));
renderTable();
await screen.findByText("Find path");
fillHashes();
openIataPicker();
checkIata("AAA");
fireEvent.click(screen.getByText("Search"));
expect(await screen.findByText(/Route search timed out/)).toBeInTheDocument();
});
it("never retries a failed search, even when the app retries other queries", async () => {
mockSearchKnownRoutes.mockRejectedValue(apiError(503, "timeout"));
mockSearchCrossIATARoutes.mockRejectedValue(apiError(503, "timeout"));
renderTable(ALL_REGIONS, new QueryClient({ defaultOptions: { queries: { retry: 2, retryDelay: 0 } } }));
await screen.findByText("Find path");
fillHashes();
fireEvent.click(screen.getByText("Search"));
expect(await screen.findByText(/Route search timed out/)).toBeInTheDocument();
expect(mockSearchKnownRoutes).toHaveBeenCalledTimes(1);
expect(mockSearchCrossIATARoutes).toHaveBeenCalledTimes(1);
});
it("keeps the results that loaded when the other call fails", async () => {
mockSearchKnownRoutes.mockResolvedValue([known(1, 2, 10)]);
mockSearchCrossIATARoutes.mockRejectedValue(new Error("network"));
renderTable();
await screen.findByText("Find path");
fillHashes();
fireEvent.click(screen.getByText("Search"));
expect(await screen.findByText("Route search failed")).toBeInTheDocument();
expect(screen.getByText("41")).toBeInTheDocument();
});
it("keeps paging for a multi-IATA region until its routes surface", async () => {
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { isBelowVersion } from "../../src/lib/version";
describe("isBelowVersion", () => {
it("compares X.Y.Z numerically", () => {
expect(isBelowVersion("2.0.2", "2.0.3")).toBe(true);
expect(isBelowVersion("2.0.9", "2.0.10")).toBe(true);
expect(isBelowVersion("1.9.9", "2.0.0")).toBe(true);
});
it("passes an equal or newer version", () => {
expect(isBelowVersion("2.0.3", "2.0.3")).toBe(false);
expect(isBelowVersion("2.1.0", "2.0.3")).toBe(false);
expect(isBelowVersion("2.0.10", "2.0.9")).toBe(false);
});
it("never blocks on a missing or malformed version", () => {
expect(isBelowVersion("2.0.2", null)).toBe(false);
expect(isBelowVersion("2.0.2", undefined)).toBe(false);
expect(isBelowVersion("2.0.2", "2.0")).toBe(false);
expect(isBelowVersion("2.0.2", "v2.0.3")).toBe(false);
expect(isBelowVersion("2.0.2", "2.0.3-rc1")).toBe(false);
expect(isBelowVersion("dev", "2.0.3")).toBe(false);
});
});