diff --git a/package-lock.json b/package-lock.json index 797f55b..5217bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 137b014..30f62db 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index c2ae75e..2a4bc6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { + diff --git a/src/api/client.ts b/src/api/client.ts index 62fc6e4..877aa2d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -113,6 +113,17 @@ export function getPacketDetail(packetHash: string): Promise { 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 { + return request("/info"); +} + export function getIatas(): Promise { 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 { - 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 { + 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 { - 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 diff --git a/src/components/VersionGate.tsx b/src/components/VersionGate.tsx new file mode 100644 index 0000000..4e2bc2a --- /dev/null +++ b/src/components/VersionGate.tsx @@ -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 ( +
+
+ +

+ {t("versionGate.message", { min, current: currentVersion })} +

+ +
+
+ ); +} diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 2b3c748..facfd2e 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -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 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 (
- +
); }); -// 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 ( -
-
-
- {crossHop.fromIata} - → - {crossHop.toIata} -
- {t("routes.hops", { count: route.totalHops })} -
-
- - {route.sourceSegment.length > 0 && ⇒} - {nodeLabel(crossHop.fromNode)} - ⇒ - {nodeLabel(crossHop.toNode)} - {route.targetSegment.length > 0 && ⇒} - -
-
- ); -} - // ids keep sorting stable when the headers are translated const buildColumns = (t: TFunction): Column[] => [ { @@ -96,7 +66,7 @@ const buildColumns = (t: TFunction): Column[] => [ { id: "route", header: t("routes.columns.route"), - cell: (r) => , + cell: (r) => , }, { id: "obs", @@ -129,7 +99,7 @@ function RouteCard({ route: r }: { route: KnownRoute }) { {r.iata} {t("routes.hops", { count: r.hopCount })} · {t("routes.obs", { count: r.observationCount, value: r.observationCount.toLocaleString() })} - +
{t("routes.first")} · @@ -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[] => [ + { + id: "area", + header: t("routes.columns.area"), + sortValue: (r) => r.area, + cell: (r) => {r.area}, + }, + { + id: "hops", + header: t("routes.columns.hops"), + sortValue: (r) => r.hopCount, + cell: (r) => r.hopCount, + }, + { + id: "route", + header: t("routes.columns.route"), + cell: (r) => , + }, + { + 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) => , + }, +]; + +function SearchRowCard({ row }: { row: SearchRow }) { + const { t } = useTranslation(); + return ( +
+
+ {row.area} + + {t("routes.hops", { count: row.hopCount })} + {row.route && <> · {t("routes.obs", { count: row.route.observationCount, value: row.route.observationCount.toLocaleString() })}} + +
+ +
+ {t("routes.last")} +
+
+ ); +} + +const renderSearchCard = (r: SearchRow) => ; + const renderRouteCard = (r: KnownRoute) => ; 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([]); - const [search, setSearch] = useState(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} /> → @@ -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} />
@@ -346,36 +420,46 @@ export function RouteTable(actions: RouteActions) { )} + {(hashError || tooManyAreas) && ( + {t(hashError ? "routes.badHash" : "routes.tooManyAreas")} + )} + {search && searchErrors.length > 0 && (searchRows?.length ?? 0) > 0 && ( +
+ {searchErrors.join(" · ")} +
+ )}
- {isCross ? ( -
- {crossLoading ? ( -
{t("routes.searching")}
- ) : crossRoutes && crossRoutes.length > 0 ? ( - crossRoutes.map((r, i) => ) - ) : ( -
{t("routes.noCross")}
- )} + {search ? ( +
+ 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} + />
) : ( -
+
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 && ( - - )} +
)} {pathKey && routeIata ? ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f864827..0db96f8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 03ee2a6..0503e69 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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é", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 86178e4..197255b 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -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", diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..2989214 --- /dev/null +++ b/src/lib/version.ts @@ -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; +} diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx index 01e675c..7ec40d1 100644 --- a/tests/App.analyzerObservationCarryOver.test.tsx +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -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: [] }), diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index 7ac2376..d7b213d 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -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 <>{children}; diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 3583cc7..074126a 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -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: [] }), diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx index cfcd911..486db57 100644 --- a/tests/App.quickObserver.test.tsx +++ b/tests/App.quickObserver.test.tsx @@ -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 }) => <> })); diff --git a/tests/App.viewOnMap.test.tsx b/tests/App.viewOnMap.test.tsx index 12e90f3..0ed374a 100644 --- a/tests/App.viewOnMap.test.tsx +++ b/tests/App.viewOnMap.test.tsx @@ -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 }) => ( <> diff --git a/tests/App.viewOnMapDisabled.test.tsx b/tests/App.viewOnMapDisabled.test.tsx index 6a4450a..48ca723 100644 --- a/tests/App.viewOnMapDisabled.test.tsx +++ b/tests/App.viewOnMapDisabled.test.tsx @@ -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 }) => ( <> diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index fac5983..df55de4 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -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"); }); }); diff --git a/tests/components/VersionGate.test.tsx b/tests/components/VersionGate.test.tsx new file mode 100644 index 0000000..b607af3 --- /dev/null +++ b/tests/components/VersionGate.test.tsx @@ -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(); +} + +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(); + }); +}); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index 60bb649..7e3dbe1 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -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 }) => ( {children} @@ -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 () => { diff --git a/tests/lib/version.test.ts b/tests/lib/version.test.ts new file mode 100644 index 0000000..b6a040e --- /dev/null +++ b/tests/lib/version.test.ts @@ -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); + }); +});