diff --git a/package-lock.json b/package-lock.json index 7441ecc0..797f55b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.1", + "version": "2.0.2", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index ebabcfa7..137b014a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.1", + "version": "2.0.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/api/client.ts b/src/api/client.ts index 8689ec05..62fc6e44 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,7 +2,7 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit"; import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, RouteEvidence, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelPage, ChannelMessage } from "../features/channels/types"; -import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; +import type { ObserverSummary, Observer, AdvertObservation, ObserverDirectoryPage, ObserverDirectorySort } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { StatsSeries, @@ -304,6 +304,46 @@ export function getNodesPage( }); } +export interface ObserverDirectoryRequest { + location?: StatsRegion; + sort?: ObserverDirectorySort; + since?: number; + until?: number; + cursor?: number; + limit?: number; + name?: string; + type?: string; + broker?: string; + status?: string; + scope?: string; +} + +export async function getObserverDirectoryPage(params: ObserverDirectoryRequest, signal?: AbortSignal): Promise { + const { location, ...filters } = params; + const page = await request("/observers/directory", { ...statsRegionParams(location), ...filters }, signal); + // A legacy detail response must never be mistaken for a paginated directory. + const isCount = (value: unknown) => value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0); + if (!page || !Array.isArray(page.items) || !Array.isArray(page.observerTypes) || typeof page.hasMore !== "boolean" || + !Number.isFinite(page.windowStart) || !Number.isFinite(page.windowEnd) || page.windowStart >= page.windowEnd || + !["traffic", "name"].includes(page.effectiveSort) || + !["complete", "partial", "unavailable"].includes(page.coverage?.status) || !isCount(page.maxObservationCount) || + page.items.some(row => !row || typeof row.id !== "string" || !isCount(row.observationCount))) { + throw new Error("Invalid observer directory response"); + } + return page; +} + +export async function supportsObserverDirectory(signal?: AbortSignal): Promise { + try { + await getObserverDirectoryPage({ limit: 1 }, signal); + return true; + } catch (error) { + if (error instanceof ApiError && (error.status === 404 || + (error.status === 400 && error.message === "failed to parse observer UUID"))) return false; + throw error; + } +} + // Paginated /observers, mirroring getNodesPage; used by the Observers table. export function getObserversPage( iatas: string[] | undefined, diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index ab2896a4..26bed734 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { BottomSheet } from "./BottomSheet"; import { ENABLED_TABS } from "../lib/constants"; +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; @@ -62,6 +63,7 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo ariaHasPopup?: "menu"; ariaExpanded?: boolean; }) { + const tap = touchClick(onClick); return ( + ); +} + // Bottom sheet listing the overflow tabs. function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) { const { t } = useTranslation(); return ( {tabs.map((tab) => ( - + onPick(tab)} /> ))} ); diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index c3cd34c5..1aad0510 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -94,7 +94,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze [messages], ); - const bottomRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(null); const viewKey = `${channel?.id ?? ""}:${scope}`; @@ -136,7 +135,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze } if (sorted.length > anchor.count && !userScrolled) { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down + // live message arrived; follow it down. Not scrollIntoView — that also scrolls the page shell + el?.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } anchor.count = sorted.length; }, [sorted.length, viewKey, isLoading, userScrolled]); @@ -221,7 +221,6 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze {sorted.map((msg) => ( ))} -
) : (
diff --git a/src/features/observers/ObserverFilterBar.tsx b/src/features/observers/ObserverFilterBar.tsx index 7c6bdb9e..1b5f6696 100644 --- a/src/features/observers/ObserverFilterBar.tsx +++ b/src/features/observers/ObserverFilterBar.tsx @@ -65,7 +65,7 @@ export function ObserverFilterBar({ const controls = (fullWidth: boolean) => ( <> - {typeOptions.length > 0 && ( + {(typeOptions.length > 0 || typeFilter) && ( ({ value: o, label: o }))} value={typeFilter} onChange={onTypeChange} fullWidth={fullWidth} /> )} {brokerOptions.length > 0 && ( diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 5bc0833e..55a72852 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useState, useMemo } from "react"; +import { lazy, Suspense, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -13,7 +13,7 @@ import { useTick } from "../../hooks/useTick"; import { ObserverFilterBar } from "./ObserverFilterBar"; import { ObserverSidebar } from "./ObserverSidebar"; import { ObserverAdverts } from "./ObserverAdverts"; -import { filterObservers } from "./observer-filter"; +import type { ObserverDirectorySort } from "./types"; import { useObserverDirectory } from "./useObserverDirectory"; import { observerDestination, observerRange } from "./observer-navigation"; import { Segmented } from "../stats/Segmented"; @@ -41,17 +41,17 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana const [broker, setBroker] = useState(""); const [scope, setScope] = useState(""); const [minimized, setMinimized] = useState(false); - const directory = useObserverDirectory(wsManager, broker); + const [sort, setSort] = useState("traffic"); + const directory = useObserverDirectory(wsManager, { sort, search, status, type, broker, scope }); const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, staleTime: 60_000 }); const scopeOptions = useScopes(scope); - const typeOptions = useMemo(() => [...new Set((directory.data ?? []).flatMap((o) => o.observerType ? [o.observerType] : []))].sort(), [directory.data]); - const observers = useMemo(() => filterObservers(directory.data ?? [], { search, status, type, scope }, now), [directory.data, search, status, type, scope, now]); + const typeOptions = directory.observerTypes; + const observers = directory.observers; const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); if (id) share.searchParams.set("range", range); if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); const select = (observer: string | null) => { setMinimized(false); setParams(observerDestination(params, observer, range)); }; const compare = (observer: string) => { - // eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback. const clickedAt = Date.now(); setActionTime(clickedAt); setParams(old => { @@ -84,8 +84,12 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana />
- void directory.refetch()} range={range} selectedId={id} onSelect={select} /> + void directory.retry()} onRefresh={() => void directory.refresh()} selectedId={id} onSelect={select} + sort={sort} onSortChange={setSort} maxObservationCount={directory.maxObservationCount} + coverage={directory.coverage} effectiveSort={directory.effectiveSort} windowEnd={directory.windowEnd} + unsupported={directory.unsupported} hasNextPage={directory.hasNextPage} + isFetchingNextPage={directory.isFetchingNextPage} onLoadMore={directory.loadMore} />
{/* below md the dashboard overlays the list, like the node detail panel */} {id ?
diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx index 13d1a018..a7de7e57 100644 --- a/src/features/observers/ObserverSidebar.tsx +++ b/src/features/observers/ObserverSidebar.tsx @@ -1,77 +1,89 @@ -import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { getTopObservers } from "../../api/client"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; +import { SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants"; import { Segmented } from "../stats/Segmented"; -import { gatedOn, sinceFor, useStatsRegion } from "../stats/useStats"; -import type { StatsRange } from "../stats/types"; import { observerName as nameOf } from "./observer-filter"; import { deriveObserverStatus } from "./observer-status"; -import type { ObserverSummary } from "./types"; +import type { ObserverDirectoryItem, ObserverDirectoryPage, ObserverDirectorySort } from "./types"; -type Sort = "activity" | "name"; -const TOP = 200; // the API's list cap - -export function ObserverSidebar({ observers, filtered, isPending, isError, onRetry, range, selectedId, onSelect }: { - observers: ObserverSummary[]; filtered: boolean; isPending: boolean; isError: boolean; onRetry: () => void; - range: StatsRange; selectedId: string | null; onSelect: (id: string) => void; +export function ObserverSidebar({ observers, filtered, isPending, isError, unsupported, onRetry, onRefresh, + sort, onSortChange, maxObservationCount, coverage, effectiveSort, windowEnd, + hasNextPage, isFetchingNextPage, onLoadMore, selectedId, onSelect }: { + observers: ObserverDirectoryItem[]; filtered: boolean; isPending: boolean; isError: boolean; + unsupported: boolean; onRetry: () => void; onRefresh?: () => void; + sort: ObserverDirectorySort; onSortChange: (sort: ObserverDirectorySort) => void; + maxObservationCount: number | null; coverage?: ObserverDirectoryPage["coverage"]; + effectiveSort?: ObserverDirectorySort; windowEnd?: number; + hasNextPage: boolean; isFetchingNextPage: boolean; onLoadMore: () => void; + selectedId: string | null; onSelect: (id: string) => void; }) { const { t } = useTranslation(); - const { where, regionKey, isResolved } = useStatsRegion(); - const [sort, setSort] = useState("activity"); - - const activity = useQuery({ - queryKey: ["observer-sidebar-activity", regionKey, range], - ...gatedOn(isResolved, () => getTopObservers(where, sinceFor(range), TOP)), - staleTime: 30_000, - }); - - const counts = useMemo(() => new Map((activity.data ?? []).map((o) => [o.observerId, o.observationCount])), [activity.data]); - const max = Math.max(1, ...counts.values()); - const rows = useMemo(() => { - const byName = (a: ObserverSummary, b: ObserverSummary) => nameOf(a).localeCompare(nameOf(b), undefined, { numeric: true }); - return [...observers].sort(sort === "name" ? byName : (a, b) => (counts.get(b.id) ?? 0) - (counts.get(a.id) ?? 0) || byName(a, b)); - }, [observers, sort, counts]); + const scroller = useRef(null); + const loadNearEnd = useCallback(() => { + const element = scroller.current; + if (element && element.clientHeight > 0 && hasNextPage && !isFetchingNextPage && !isError && !isPending && + element.scrollHeight - element.scrollTop - element.clientHeight < SCROLL_BOTTOM_THRESHOLD_PX) onLoadMore(); + }, [hasNextPage, isFetchingNextPage, isError, isPending, onLoadMore]); + useEffect(() => { + loadNearEnd(); + const element = scroller.current; + if (!element || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(loadNearEnd); + observer.observe(element); + return () => observer.disconnect(); + }, [loadNearEnd, observers.length]); + const messageClass = "px-3 py-3 text-center font-mono text-[11px] text-text-dim"; + const retry = ; return ( - // a card at md+, an edge-to-edge list like the Nodes tab below it
{t("tabs.Observers")}
- setSort(v as Sort)} - options={[{ value: "activity", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> + onSortChange(v as ObserverDirectorySort)} + options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} />
- {isPending ?
{t("common.loading")}
- : isError ? - : rows.length === 0 ?
{t(filtered ? "observerPage.noMatches" : "observerPage.none")}
- : ( -
- {rows.map((o) => { + {onRefresh && !unsupported && } + {coverage && coverage.status !== "complete" &&

+ {t(maxObservationCount != null ? "observerPage.countsPartial" + : effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")} +

} + {windowEnd != null &&
+

{t("observerPage.directoryPeriod")}

+

{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}

+
} + {unsupported ?

{t("observerPage.upgradeRequired")}

+
+ : isPending ?
{t("common.loading")}
+ : isError && observers.length === 0 ? retry + : observers.length === 0 ?
{t(filtered ? "observerPage.noMatches" : "observerPage.none")}
+ :
+
+ {observers.map(o => { const active = o.id === selectedId; - const count = counts.get(o.id); + const count = o.observationCount; return ( - ); })}
- )} + {isFetchingNextPage ?
{t("common.loading")}
+ : isError ? retry + : hasNextPage ? + :
{t("observerPage.end")}
} +
}
); } diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index ec657135..2ce75f15 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -49,3 +49,29 @@ export interface AdvertObservation { nodeName?: string; nodePublicKey?: string; } + +export type ObserverDirectorySort = "traffic" | "name"; + +export interface ObserverDirectoryItem extends ObserverSummary { + observationCount: number | null; +} + +export interface ObserverDirectoryPage { + items: ObserverDirectoryItem[]; + nextCursor?: number; + hasMore: boolean; + generatedAt: number; + windowStart: number; + windowEnd: number; + sort: ObserverDirectorySort; + effectiveSort: ObserverDirectorySort; + coverage: { + status: "complete" | "partial" | "unavailable"; + expectedHours: number; + completeHours: number; + partialHours: number; + missingHours: number; + }; + maxObservationCount: number | null; + observerTypes: string[]; +} diff --git a/src/features/observers/useObserverDirectory.ts b/src/features/observers/useObserverDirectory.ts index 138be4d3..5ead4a19 100644 --- a/src/features/observers/useObserverDirectory.ts +++ b/src/features/observers/useObserverDirectory.ts @@ -1,39 +1,120 @@ -import { useCallback, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { getObserversPage } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { getObserverDirectoryPage, supportsObserverDirectory } from "../../api/client"; +import { API_BASE } from "../../lib/constants"; import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; -import { patchObserverSummary } from "./observer-updates"; -import type { ObserverSummary } from "./types"; +import { useStatsRegion } from "../stats/useStats"; +import type { ObserverDirectoryItem, ObserverDirectoryPage, ObserverDirectorySort } from "./types"; import type { WsManager } from "../../api/ws-manager"; import type { WsObserverStatus } from "../../types/ws"; -const PAGE = 200; // the API's list cap -const MAX_PAGES = 10; - -// The whole region fits in a page or two, so sorting and the other filters stay client-side and instant. -async function allObservers(iatas: string[] | undefined, broker: string): Promise { - const items: ObserverSummary[] = []; - let cursor: number | undefined; - for (let i = 0; i < MAX_PAGES; i++) { - const page = await getObserversPage(iatas, { cursor, limit: PAGE, broker: broker || undefined }); - items.push(...page.items); - if (!page.hasMore || page.nextCursor == null) break; - cursor = page.nextCursor; - } - return items; +export interface ObserverDirectoryOptions { + sort: ObserverDirectorySort; + search: string; + status: string; + type: string; + broker: string; + scope: string; } -// The region's observers, kept live by WS status events between the minute refetches. -export function useObserverDirectory(wsManager: WsManager, broker: string) { - const { iatas, regionKey, isResolved } = useRegion(); +type Continuation = { cursor: number; since: number; until: number } | undefined; +const retryDelay = (attempt: number, error: Error) => + (error as { retryAfterMs?: number }).retryAfterMs ?? Math.min(1000 * 2 ** attempt, 30_000); + +export function useObserverDirectory(wsManager: WsManager, options: ObserverDirectoryOptions) { + const { where, regionKey, isResolved } = useStatsRegion(); + const { sort, search, status, type, broker, scope } = options; const queryClient = useQueryClient(); - const queryKey = useMemo(() => ["observer-directory", regionKey, broker], [regionKey, broker]); - const list = useQuery({ queryKey, queryFn: () => allObservers(iatas, broker), enabled: isResolved !== false, staleTime: 30_000, refetchInterval: 60_000 }); - const onStatus = useCallback( - (data: WsObserverStatus["data"]) => queryClient.setQueryData(queryKey, (old) => patchObserverSummary(old, data)), - [queryClient, queryKey], - ); + const capability = useQuery({ + queryKey: ["observer-directory-support", API_BASE], + queryFn: ({ signal }) => supportsObserverDirectory(signal), + enabled: isResolved !== false, + staleTime: 60_000, + retryDelay, + }); + const queryKey = useMemo(() => ["observer-directory", API_BASE, regionKey, sort, search, status, type, broker, scope], + [regionKey, sort, search, status, type, broker, scope]); + const list = useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam, signal }) => getObserverDirectoryPage({ + location: where, sort, name: search || undefined, status: status || undefined, + type: type || undefined, broker: broker || undefined, scope: scope || undefined, ...pageParam, limit: 200, + }, signal), + // Refetch starts unbounded; later pages take their bounds from the new first response. + initialPageParam: undefined as Continuation, + getNextPageParam: (last, pages, _lastParam, params): Continuation => { + const first = pages[0]; + if (!first || last.effectiveSort !== first.effectiveSort || !last.hasMore || last.nextCursor == null || last.nextCursor === 0 || + params.some(p => p?.cursor === last.nextCursor)) return undefined; + return { cursor: last.nextCursor, since: first.windowStart, until: first.windowEnd }; + }, + enabled: isResolved !== false && capability.data === true, + staleTime: Infinity, + refetchInterval: 60_000, + gcTime: 0, + retryDelay, + }); + const first = list.data?.pages[0]; + const sortChanged = list.data?.pages.some(page => page.effectiveSort !== first?.effectiveSort) ?? false; + const resetKey = JSON.stringify(queryKey); + const restartedKey = useRef(null); + useEffect(() => { + // Coverage can oscillate during corrections; restart automatically only once per query. + if (sortChanged && restartedKey.current !== resetKey) { + restartedKey.current = resetKey; + void queryClient.resetQueries({ queryKey, exact: true }); + } + }, [sortChanged, resetKey, queryClient, queryKey]); + + const onStatus = useCallback((data: WsObserverStatus["data"]) => { + queryClient.setQueryData>(queryKey, old => old && ({ + ...old, + pages: old.pages.map(page => ({ ...page, items: page.items.map(row => row.id !== data.observerId ? row : { + ...row, status: data.online ? "online" : "offline", lastStatusAt: data.lastStatusAt || row.lastStatusAt, + }) })), + })); + }, [queryClient, queryKey]); useWsObserverStatusHandler(wsManager, onStatus); - return list; + + const observers = useMemo(() => { + const seen = new Set(); + const rows: ObserverDirectoryItem[] = []; + for (const page of list.data?.pages ?? []) { + if (page.effectiveSort !== list.data?.pages[0]?.effectiveSort) break; + for (const row of page.items) { + if (!seen.has(row.id)) { seen.add(row.id); rows.push(row); } + } + } + return rows; + }, [list.data]); + const { fetchNextPage, hasNextPage, isFetching } = list; + const loadMore = useCallback(async () => { + if (hasNextPage && !isFetching) await fetchNextPage({ cancelRefetch: false }); + }, [fetchNextPage, hasNextPage, isFetching]); + const refresh = async () => { + restartedKey.current = null; + await queryClient.resetQueries({ queryKey, exact: true }); + }; + const retry = async () => { + if (capability.data !== true) await capability.refetch(); + else if (list.isFetchNextPageError && !sortChanged) await loadMore(); + else await refresh(); + }; + return { + observers, + observerTypes: first?.observerTypes ?? [], + maxObservationCount: first?.maxObservationCount ?? null, + coverage: first?.coverage, + effectiveSort: first?.effectiveSort, + windowEnd: first?.windowEnd, + resetKey, + hasNextPage: list.hasNextPage, + isFetchingNextPage: list.isFetchingNextPage, + isPending: !capability.isError && capability.data !== false && (capability.isPending || list.isPending), + isError: capability.isError || list.isError || sortChanged, + unsupported: capability.data === false, + loadMore, + retry, + refresh, + }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 614c8e61..f8648274 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -333,6 +333,15 @@ } }, "observerPage": { + "countsPartial": "Counts use available history; coverage is incomplete.", + "directoryPeriod": "Available history, up to 7 days", + "refresh": "Refresh", + "loadMore": "Load more observers", + "end": "All observers loaded", + "upgradeRequired": "Observer pagination requires a server upgrade.", + "countsUnavailable": "Traffic counts unavailable for this window.", + "countsUnavailableName": "Traffic counts unavailable for this window; showing Name order.", + "countsThrough": "Counts through {{time}} UTC", "back": "Back to observers", "detail": "Observer detail", "range": "Time range", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index fc293881..03ee2a65 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -341,6 +341,15 @@ } }, "observerPage": { + "countsPartial": "Les compteurs utilisent l’historique disponible ; la couverture est incomplète.", + "directoryPeriod": "Historique disponible, jusqu’à 7 jours", + "refresh": "Actualiser", + "loadMore": "Charger plus d’observateurs", + "end": "Tous les observateurs sont chargés", + "upgradeRequired": "La pagination des observateurs nécessite une mise à jour du serveur.", + "countsUnavailable": "Les compteurs de trafic sont indisponibles pour cette période.", + "countsUnavailableName": "Les compteurs de trafic sont indisponibles pour cette période ; tri par nom.", + "countsThrough": "Comptages jusqu’au {{time}} UTC", "back": "Retour aux observateurs", "detail": "Détail de l'observateur", "range": "Période", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 2b6507bf..86178e4d 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -333,6 +333,15 @@ } }, "observerPage": { + "countsPartial": "Antalen baseras på tillgänglig historik; täckningen är ofullständig.", + "directoryPeriod": "Tillgänglig historik, upp till 7 dagar", + "refresh": "Uppdatera", + "loadMore": "Ladda fler observatörer", + "end": "Alla observatörer har laddats", + "upgradeRequired": "Sidindelning av observatörer kräver en serveruppgradering.", + "countsUnavailable": "Trafikräkning är inte tillgänglig för denna period.", + "countsUnavailableName": "Trafikräkning är inte tillgänglig för denna period; sorterar efter namn.", + "countsThrough": "Antal fram till {{time}} UTC", "back": "Tillbaka till observatörer", "detail": "Observatörsdetaljer", "range": "Tidsintervall", diff --git a/src/lib/touch-click.ts b/src/lib/touch-click.ts new file mode 100644 index 00000000..9daeec1b --- /dev/null +++ b/src/lib/touch-click.ts @@ -0,0 +1,16 @@ +import type { TouchEvent } from "react"; + +// iOS Safari drops a tap's click when new tappable content appears mid-tap (the live packet list +// does that constantly), so a touch acts on touchend and cancels the click iOS might still send. +export function touchClick(onActivate: () => void) { + return { + onClick: onActivate, + onTouchEnd: (e: TouchEvent) => { + const touch = e.changedTouches[0]; + const r = e.currentTarget.getBoundingClientRect(); + if (!touch || touch.clientX < r.left || touch.clientX > r.right || touch.clientY < r.top || touch.clientY > r.bottom) return; + e.preventDefault(); + onActivate(); + }, + }; +} diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index 4d8f6e9c..7ac23766 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -20,7 +20,7 @@ vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObser return
setFilter(e.target.value)} />{params.toString()}
Routes
; } })); vi.mock("../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: () =>

Observer directory

})); -vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ data: [], isPending: false, isError: false, refetch: () => {} }) })); +vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ observers: [], observerTypes: [], isPending: false, isError: false, unsupported: false, hasNextPage: false, isFetchingNextPage: false, maxObservationCount: null, resetKey: "test", retry: () => {}, refresh: () => {}, loadMore: () => {} }) })); vi.mock("../src/features/stats/MeshTab", () => ({ MeshTab: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { const [value, setValue] = useState(""); return <> setValue(e.target.value)} />; diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index be2c0398..0c78aa5c 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -41,4 +41,32 @@ describe("BottomNav", () => { const more = screen.getByText("More").closest("button")!; expect(more.className).toContain("text-primary"); }); + + // iOS drops the click when live content lands mid-tap, so a touch acts on touchend alone + const tapEnd = (el: Element, x = 0, y = 0) => fireEvent.touchEnd(el, { changedTouches: [{ clientX: x, clientY: y }] }); + + it("switches tab on touchend and cancels the trailing click", () => { + const onTabChange = vi.fn(); + render(); + const notPrevented = tapEnd(screen.getByRole("tab", { name: "Channels" })); + expect(onTabChange).toHaveBeenCalledTimes(1); + expect(onTabChange).toHaveBeenCalledWith("Channels"); + expect(notPrevented).toBe(false); + }); + + it("ignores a touch that lifts off the button", () => { + const onTabChange = vi.fn(); + render(); + tapEnd(screen.getByRole("tab", { name: "Channels" }), 50, 50); + expect(onTabChange).not.toHaveBeenCalled(); + }); + + it("opens More and picks an overflow tab by touch", () => { + const onTabChange = vi.fn(); + render(); + tapEnd(screen.getByText("More").closest("button")!); + tapEnd(screen.getByRole("menuitem", { name: "Routes" })); + expect(onTabChange).toHaveBeenCalledWith("Routes"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }); }); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index d95bf885..17624be0 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -48,7 +48,7 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages)); vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null }); - Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.scrollTo = vi.fn(); }); describe("channel scope evidence", () => { diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index 65e34819..e2e7e136 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MessagePanel } from "../../../src/features/channels/MessagePanel"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; @@ -121,3 +121,30 @@ describe("MessagePanel heard badge", () => { expect(screen.getByTitle("Entendu 1 fois")).toBeInTheDocument(); }); }); + +describe("MessagePanel live follow", () => { + it("scrolls only its own list when a live message arrives, not the page", async () => { + const scrollTo = vi.fn(); + window.HTMLElement.prototype.scrollTo = scrollTo as unknown as typeof HTMLElement.prototype.scrollTo; + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await screen.findByText("live two"); + + const live = { ...liveMsgB, packetHash: "ph-live-c", content: "live three", sentAt: 5000 } as ChannelMessage; + act(() => { + qc.setQueryData(["channel-messages", channel.id, "*", ""], (old: { pages: { items: ChannelMessage[] }[] }) => ({ + ...old, + pages: [{ ...old.pages[0], items: [live, ...old.pages[0].items] }, ...old.pages.slice(1)], + })); + }); + await screen.findByText("live three"); + + // scrollIntoView also scrolls every ancestor up to the document, shoving the whole app shell up + expect(window.HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + expect(scrollTo).toHaveBeenCalled(); + }); +}); diff --git a/tests/features/observers/ObserverFilterBar.test.tsx b/tests/features/observers/ObserverFilterBar.test.tsx index c5585ec2..2c388b63 100644 --- a/tests/features/observers/ObserverFilterBar.test.tsx +++ b/tests/features/observers/ObserverFilterBar.test.tsx @@ -1,9 +1,9 @@ import { expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { ObserverFilterBar } from "../../../src/features/observers/ObserverFilterBar"; import i18n from "../../../src/i18n"; -function renderBar() { +function renderBar(typeFilter = "", typeOptions = ["mqtt"], onTypeChange = vi.fn()) { return render( { for (const label of ["État", "Courtier", "Portée"]) expect(screen.getByText(label)).toBeInTheDocument(); expect(screen.getByPlaceholderText(/par nom/)).toBeInTheDocument(); }); + +it("keeps an active type filter clearable when the server facets become empty", () => { + const onTypeChange = vi.fn(); + renderBar("mqtt", [], onTypeChange); + fireEvent.click(screen.getByRole("button", { name: /Type/ })); + fireEvent.click(screen.getByRole("option", { name: "All", exact: true })); + expect(onTypeChange).toHaveBeenCalledWith(""); +}); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index 43550ded..30127fec 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -1,10 +1,11 @@ import { describe, it, expect, vi } from "vitest"; import "../../../src/i18n"; -import { render, screen, fireEvent, within, waitFor } from "@testing-library/react"; +import { act, render, screen, fireEvent, within, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; import type { WsManager } from "../../../src/api/ws-manager"; import type { ObserverSummary } from "../../../src/features/observers/types"; +import { useObserverDirectory } from "../../../src/features/observers/useObserverDirectory"; import { ObserverPage } from "../../../src/features/observers/ObserverPage"; const directory: ObserverSummary[] = [ @@ -12,7 +13,12 @@ const directory: ObserverSummary[] = [ { id: "observer-b", displayName: "Basement B", iata: "YOW", status: "offline", observerType: "RemoteTerm" }, ]; vi.mock("../../../src/features/observers/useObserverDirectory", () => ({ - useObserverDirectory: () => ({ data: directory, isPending: false, isError: false, refetch: vi.fn() }), + useObserverDirectory: vi.fn((_ws: unknown, options: { search?: string } | string) => { + const search = typeof options === "string" ? "" : options.search ?? ""; + const rows = directory.filter(o => search === "%" || o.displayName?.toLowerCase().includes(search)); + return { data: directory, observers: rows, observerTypes: ["meshcore-ha", "RemoteTerm"], isPending: false, + isError: false, retry: vi.fn(), refetch: vi.fn(), loadMore: vi.fn(), resetKey: search }; + }), })); vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); vi.mock("../../../src/api/client", () => ({ getBrokers: () => Promise.resolve([]) })); @@ -168,3 +174,22 @@ it("rejects duplicate, malformed and future comparison anchors", async () => { view(`?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=${hour}&compareUntil=${hour}`); expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); }); + +it("preserves server-filtered wildcard results instead of filtering the loaded page again", async () => { + view(); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "%" } }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 350)); }); + expect(list().getAllByRole("button")).toHaveLength(2); +}); + + +it("changes the detail range without changing the directory request", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); + const before = vi.mocked(useObserverDirectory).mock.calls.at(-1)![1]; + expect(before).not.toHaveProperty("range"); + fireEvent.click(within(screen.getByRole("group", { name: "Time range" })).getByRole("button", { name: "30d" })); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 30d"); + expect(vi.mocked(useObserverDirectory).mock.calls.at(-1)![1]).toEqual(before); + expect(list().getAllByRole("button")).toHaveLength(2); +}); diff --git a/tests/features/observers/ObserverSidebar.test.tsx b/tests/features/observers/ObserverSidebar.test.tsx index 5aec84ef..a7ebe229 100644 --- a/tests/features/observers/ObserverSidebar.test.tsx +++ b/tests/features/observers/ObserverSidebar.test.tsx @@ -1,69 +1,98 @@ -import { beforeEach, expect, it, vi } from "vitest"; +import { expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import "../../../src/i18n"; import { ObserverSidebar } from "../../../src/features/observers/ObserverSidebar"; -import { getTopObservers } from "../../../src/api/client"; -import type { ObserverSummary } from "../../../src/features/observers/types"; +import { directoryRow } from "../../fixtures/observer-directory"; -const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true }; -vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); -vi.mock("../../../src/api/client", () => ({ getTopObservers: vi.fn() })); +const props = { + observers: [directoryRow("Charlie", 900), directoryRow("Alpha", 100), directoryRow("Zero", 0), directoryRow("Unavailable", null)], + filtered: false, isPending: false, isError: false, unsupported: false, onRetry: vi.fn(), + sort: "traffic" as const, onSortChange: vi.fn(), maxObservationCount: 1000, + hasNextPage: true, isFetchingNextPage: false, onLoadMore: vi.fn(), + selectedId: "Alpha", onSelect: vi.fn(), +}; -const observers: ObserverSummary[] = [ - { id: "a", displayName: "Alpha", iata: "YOW", status: "online" }, - { id: "b", displayName: "Bravo", iata: "YOW", status: "offline" }, - { id: "c", displayName: "Charlie", iata: "YOW", status: "online" }, -]; - -beforeEach(() => { - vi.mocked(getTopObservers).mockReset().mockResolvedValue([ - { observerId: "c", displayName: "Charlie", iata: "YOW", observationCount: 900 }, - { observerId: "a", displayName: "Alpha", iata: "YOW", observationCount: 100 }, - ] as never); +it("preserves server order and distinguishes known zero from unavailable counts", () => { + render(); + const rows = screen.getAllByRole("option"); + expect(rows.map(row => row.textContent)).toEqual(["Receiver Charlie900", "Receiver Alpha100", "Receiver Zero0", "Receiver Unavailable-"]); + expect(rows[1]).toHaveAttribute("aria-selected", "true"); + expect(rows[1].querySelector('[style]')).toHaveStyle({ width: "10%" }); + expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" }); + expect(rows[3].querySelector('[style]')).toBeNull(); + fireEvent.click(rows[0]); + expect(props.onSelect).toHaveBeenCalledWith("Charlie"); }); -function view({ list = observers, filtered = false, onSelect = vi.fn() } = {}) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - render( - - {}} range="7d" selectedId="a" onSelect={onSelect} /> - , - ); - return onSelect; -} -const names = () => screen.getAllByRole("option").map((o) => o.textContent); +it("requests server Name sorting and keeps normalization fixed when pages append", () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: "Name" })); + expect(props.onSortChange).toHaveBeenCalledWith("name"); + rerender(); + expect(screen.getByRole("option", { name: /Receiver Alpha/ }).querySelector('[style]')).toHaveStyle({ width: "10%" }); + expect(screen.getByRole("option", { name: /Receiver Later/ }).querySelector('[style]')).toHaveStyle({ width: "100%" }); +}); -it("lists every observer busiest first, with idle ones last", async () => { - view(); - await screen.findByText("900"); - expect(names()).toEqual([expect.stringMatching(/^Charlie.*900/), expect.stringMatching(/^Alpha.*100/), expect.stringMatching(/^Bravo/)]); - expect(screen.getByRole("option", { name: /Alpha/ })).toHaveAttribute("aria-selected", "true"); - expect(getTopObservers).toHaveBeenCalledWith(["YOW"], expect.any(Number), 200); +it("loads near the scroll end but not at the top of a long list or while loading", () => { + const onLoadMore = vi.fn(); + const { rerender } = render(); + const scroll = screen.getByRole("listbox").parentElement!; + Object.defineProperties(scroll, { clientHeight: { value: 400 }, scrollHeight: { value: 2000 }, scrollTop: { value: 0, writable: true } }); + fireEvent.scroll(scroll); + expect(onLoadMore).not.toHaveBeenCalled(); + scroll.scrollTop = 1500; + fireEvent.scroll(scroll); + expect(onLoadMore).toHaveBeenCalledTimes(1); + rerender(); + fireEvent.scroll(scroll); + expect(onLoadMore).toHaveBeenCalledTimes(1); }); -it("sorts by name on request", async () => { - view(); - await screen.findByText("900"); - fireEvent.click(screen.getByRole("button", { name: "Name" })); - expect(names().map((n) => n?.match(/^[A-Z][a-z]+/)?.[0])).toEqual(["Alpha", "Bravo", "Charlie"]); +it("fills a short viewport automatically, and stops on errors or the final page", () => { + const height = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(500); + const scrollHeight = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(100); + const onLoadMore = vi.fn(); + try { + const { rerender } = render(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + rerender(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole("option")).toHaveLength(4); + fireEvent.click(screen.getByRole("button", { name: /Retry/ })); + expect(props.onRetry).toHaveBeenCalled(); + rerender(); + expect(onLoadMore).toHaveBeenCalledTimes(1); + expect(screen.getByText("All observers loaded")).toBeInTheDocument(); + } finally { height.mockRestore(); scrollHeight.mockRestore(); } }); -it("selects an observer on click", async () => { - const onSelect = view(); - fireEvent.click(await screen.findByRole("option", { name: /Charlie/ })); - expect(onSelect).toHaveBeenCalledWith("c"); +it("explains incomplete analytics and the server's effective Name order", () => { + render(); + expect(screen.getByText(/Traffic counts unavailable/)).toHaveTextContent(/Name order/); + expect(screen.getAllByRole("option")[0]).toHaveTextContent("Receiver Charlie-"); + expect(screen.getAllByRole("option")[0].querySelector('[style]')).toBeNull(); }); -it("says when the filters match nothing", () => { - view({ list: [], filtered: true }); +it("shows upgrade and retry, loading, and empty-filter states", () => { + const { rerender } = render(); + expect(screen.getByText(/server upgrade/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Retry/ })).toBeInTheDocument(); + rerender(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + rerender(); expect(screen.getByText("No matches")).toBeInTheDocument(); }); -it("does not load activity counts for every IATA while the region is unresolved", async () => { - region.isResolved = false; - view(); - await Promise.resolve(); - expect(getTopObservers).not.toHaveBeenCalled(); - region.isResolved = true; + +it.each(["partial", "unavailable"] as const)("renders available traffic and zero counts without a Name fallback when coverage is %s", status => { + render(); + const rows = screen.getAllByRole("option"); + expect(rows.map(row => row.textContent)).toEqual(["Receiver Charlie900", "Receiver Alpha100", "Receiver Zero0"]); + expect(rows[0].querySelector('[style]')).toHaveStyle({ width: "90%" }); + expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" }); + expect(screen.queryByText(/counts unavailable/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Name order/)).not.toBeInTheDocument(); + expect(screen.getByText(/coverage is incomplete/)).toBeInTheDocument(); + expect(screen.getByText(/Available history, up to 7 days/)).toBeInTheDocument(); }); diff --git a/tests/features/observers/useObserverDirectory.test.tsx b/tests/features/observers/useObserverDirectory.test.tsx new file mode 100644 index 00000000..ca91c998 --- /dev/null +++ b/tests/features/observers/useObserverDirectory.test.tsx @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import type { WsManager } from "../../../src/api/ws-manager"; +import type { WsObserverStatus } from "../../../src/types/ws"; +import { useObserverDirectory, type ObserverDirectoryOptions } from "../../../src/features/observers/useObserverDirectory"; +import { directoryPage, directoryRow } from "../../fixtures/observer-directory"; + +const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true, emptyRegion: undefined as string | undefined }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +let statusHandler: (data: WsObserverStatus["data"]) => void; +const ws = { onObserverStatus: (handler: typeof statusHandler) => { statusHandler = handler; return () => {}; } } as WsManager; +const defaults: ObserverDirectoryOptions = { sort: "traffic", search: "", status: "", type: "", broker: "", scope: "" }; +const reply = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const failure = (status: number, message = "failed") => reply({ error: { code: "error", message } }, status); +let requests: URL[]; +let respond: (url: URL) => Response | Promise; + +beforeEach(() => { + requests = []; + region.isResolved = true; region.iatas = ["YOW"]; region.regionKey = "YOW"; region.emptyRegion = undefined; + respond = () => reply(directoryPage([directoryRow("first")])); + vi.stubGlobal("fetch", vi.fn(async (input: string) => { + const url = new URL(input); requests.push(url); + return url.search === "?limit=1" ? reply(directoryPage([])) : respond(url); + })); +}); +afterEach(() => vi.unstubAllGlobals()); +function view(options = defaults) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + return renderHook(p => useObserverDirectory(ws, p), { wrapper, initialProps: options }); +} +const listingRequests = () => requests.filter(url => url.search !== "?limit=1"); + +it("loads only the first page initially, then returns later counts beyond 200 without duplicates", async () => { + const first = Array.from({ length: 200 }, (_, i) => directoryRow(String(i), 1000 - i)); + let finish: (response: Response) => void; + respond = url => url.searchParams.has("cursor") + ? new Promise(resolve => { finish = resolve; }) + : reply(directoryPage(first, { hasMore: true, nextCursor: 200 })); + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(200)); + expect(listingRequests()).toHaveLength(1); + expect(listingRequests()[0].pathname).toMatch(/\/observers\/directory$/); + expect(result.current.observerTypes).toEqual(["meshcoretomqtt", "RemoteTerm"]); + act(() => { void result.current.loadMore(); void result.current.loadMore(); }); + await waitFor(() => expect(listingRequests()).toHaveLength(2)); + expect(Object.fromEntries(listingRequests()[1].searchParams)).toEqual({ + iatas: "YOW", sort: "traffic", since: "1790557200000", until: "1791162000000", cursor: "200", limit: "200", + }); + await act(async () => finish!(reply(directoryPage([first[199], directoryRow("later", 25), directoryRow("zero", 0)], { maxObservationCount: 2000 })))); + await waitFor(() => expect(result.current.observers).toHaveLength(202)); + expect(result.current.observers.at(-2)?.observationCount).toBe(25); + expect(result.current.observers.at(-1)?.observationCount).toBe(0); + expect(result.current.maxObservationCount).toBe(1000); + expect(result.current.hasNextPage).toBe(false); +}); + +it("sends every filter but lets the server choose the rolling seven-day window", async () => { + const { result } = view({ ...defaults, search: "roof", status: "online", type: "RemoteTerm", broker: "local", scope: "#test" }); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + const p = listingRequests()[0].searchParams; + expect(Object.fromEntries(p)).toMatchObject({ iatas: "YOW", name: "roof", status: "online", type: "RemoteTerm", broker: "local", scope: "#test", sort: "traffic" }); + expect(p.has("since")).toBe(false); + expect(p.has("until")).toBe(false); +}); + +it("ignores a slow old-filter page and restarts for new filters and sort", async () => { + let finish: (response: Response) => void; + respond = url => url.searchParams.get("name") === "new" + ? reply(directoryPage([directoryRow("new")], { effectiveSort: "name" })) + : url.searchParams.has("cursor") ? new Promise(resolve => { finish = resolve; }) + : reply(directoryPage([directoryRow("old")], { hasMore: true, nextCursor: 1 })); + const { result, rerender } = view(); + await waitFor(() => expect(result.current.observers[0]?.id).toBe("old")); + act(() => { void result.current.loadMore(); }); + await waitFor(() => expect(listingRequests()).toHaveLength(2)); + rerender({ ...defaults, search: "new", sort: "name" }); + await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["new"])); + await act(async () => finish!(reply(directoryPage([directoryRow("stale")])))); + expect(result.current.observers.map(o => o.id)).toEqual(["new"]); + expect(listingRequests().at(-1)?.searchParams.get("sort")).toBe("name"); + expect(listingRequests().at(-1)?.searchParams.has("cursor")).toBe(false); +}); + +it("restarts at page one when the effective sort changes between pages", async () => { + let firstPages = 0; + respond = url => url.searchParams.has("cursor") + ? reply(directoryPage([directoryRow("wrong-order")], { effectiveSort: "name" })) + : reply(++firstPages === 1 ? directoryPage([directoryRow("old")], { hasMore: true, nextCursor: 1 }) + : directoryPage([directoryRow("fresh")], { effectiveSort: "name" })); + const { result } = view(); + await waitFor(() => expect(result.current.observers[0]?.id).toBe("old")); + act(() => { void result.current.loadMore(); }); + await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["fresh"])); + expect(firstPages).toBe(2); +}); + +it("stops automatic restart loops if effective sort keeps changing, and allows a manual refresh", async () => { + let firstPages = 0; + respond = url => url.searchParams.has("cursor") + ? reply(directoryPage([directoryRow("wrong-order")], { effectiveSort: "name" })) + : (++firstPages, reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor: 1 }))); + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + await act(async () => { await result.current.loadMore(); }); + await waitFor(() => expect(firstPages).toBe(2)); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + await act(async () => { await result.current.loadMore(); }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(firstPages).toBe(2); + expect(result.current.observers.map(o => o.id)).toEqual(["first"]); + await act(async () => { await result.current.retry(); }); + await waitFor(() => expect(firstPages).toBe(3)); +}); + +it("keeps rows on transient continuation errors and retries the same cursor", async () => { + let attempts = 0; + respond = url => !url.searchParams.has("cursor") ? reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor: 1 })) + : ++attempts === 1 ? failure(503) : reply(directoryPage([directoryRow("second")])); + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + await act(async () => { await result.current.loadMore(); }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.observers[0].id).toBe("first"); + await act(async () => { await result.current.retry(); }); + await waitFor(() => expect(result.current.observers).toHaveLength(2)); + expect(listingRequests()[1].search).toBe(listingRequests()[2].search); +}); + +it("stops on a repeated cursor and leaves loaded membership/order/counts intact on live status", async () => { + respond = url => reply(directoryPage([directoryRow(url.searchParams.has("cursor") ? "second" : "first")], { hasMore: true, nextCursor: 1 })); + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + await act(async () => { await result.current.loadMore(); }); + await waitFor(() => expect(result.current.hasNextPage).toBe(false)); + act(() => statusHandler({ observerId: "first", online: false, displayName: "renamed", lastStatusAt: Date.now() - 600000 } as WsObserverStatus["data"])); + await waitFor(() => expect(result.current.observers[0].status).toBe("offline")); + expect(result.current.observers.map(o => [o.id, o.displayName, o.observationCount])).toEqual([["first", "Receiver first", 100], ["second", "Receiver second", 100]]); +}); + +it("does not request any directory while the region is unresolved", async () => { + region.isResolved = false; + view(); + await act(async () => {}); + expect(requests).toHaveLength(0); +}); + +it("passes an empty region by slug instead of loading all observers", async () => { + region.iatas = []; region.emptyRegion = "empty"; + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + expect(listingRequests()[0].searchParams.get("region")).toBe("empty"); +}); + +it.each([404, 400])("shows upgrade required only for the documented legacy probe response (%s), and can detect an upgrade", async status => { + const fetcher = vi.mocked(fetch); + fetcher.mockImplementation(async input => { + const url = new URL(String(input)); requests.push(url); + return failure(status, "failed to parse observer UUID"); + }); + const { result } = view(); + await waitFor(() => expect(result.current.unsupported).toBe(true)); + expect(requests.map(u => u.search)).toEqual(["?limit=1"]); + fetcher.mockImplementation(async input => { + const url = new URL(String(input)); requests.push(url); + return reply(directoryPage([directoryRow("upgraded")])); + }); + await act(async () => { await result.current.retry(); }); + await waitFor(() => expect(result.current.observers[0]?.id).toBe("upgraded")); +}); + +it.each([400, 500, 503])("does not mistake a probe failure (%s) for an older server", async status => { + vi.mocked(fetch).mockResolvedValue(failure(status, "different failure")); + const { result } = view(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.unsupported).toBe(false); +}); + +it("rejects a successful probe lacking the directory contract", async () => { + vi.mocked(fetch).mockResolvedValue(reply({ items: [], hasMore: false })); + const { result } = view(); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.unsupported).toBe(false); +}); + +it("repeats every filter on continuation using the response window, then clears pages on refresh", async () => { + let version = 0; + respond = url => reply(directoryPage([directoryRow(url.searchParams.has("cursor") ? "later" : `first-${version}`)], { + hasMore: !url.searchParams.has("cursor"), nextCursor: url.searchParams.has("cursor") ? undefined : 200, + maxObservationCount: version === 0 ? 1000 : 2000, + })); + const { result } = view({ ...defaults, search: "roof", type: "RemoteTerm", status: "online", broker: "local", scope: "#test" }); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + await act(async () => { await result.current.loadMore(); }); + await waitFor(() => expect(result.current.observers).toHaveLength(2)); + expect(Object.fromEntries(listingRequests()[1].searchParams)).toEqual({ + iatas: "YOW", sort: "traffic", name: "roof", type: "RemoteTerm", status: "online", broker: "local", scope: "#test", + since: "1790557200000", until: "1791162000000", cursor: "200", limit: "200", + }); + version = 1; + await act(async () => { await result.current.refresh(); }); + await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["first-1"])); + expect(result.current.maxObservationCount).toBe(2000); + expect(listingRequests().at(-1)?.searchParams.has("cursor")).toBe(false); +}); + +it("starts a new first page with a server-chosen window on region changes", async () => { + respond = url => reply(directoryPage([directoryRow(url.searchParams.get("iatas")!)])); + const { result, rerender } = view(); + await waitFor(() => expect(result.current.observers[0]?.id).toBe("YOW")); + region.iatas = ["YVR"]; region.regionKey = "YVR"; + rerender({ ...defaults }); + await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["YVR"])); + const p = listingRequests().at(-1)!.searchParams; + expect(p.has("cursor")).toBe(false); + expect(p.has("since")).toBe(false); + expect(p.has("until")).toBe(false); +}); + +it.each([undefined, 0])("stops when a nonterminal response has no usable cursor (%s)", async nextCursor => { + respond = () => reply(directoryPage([directoryRow("first")], { hasMore: true, nextCursor })); + const { result } = view(); + await waitFor(() => expect(result.current.observers).toHaveLength(1)); + expect(result.current.hasNextPage).toBe(false); +}); + +it("refreshes loaded rows each minute so fetched online status does not stay green indefinitely", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-04T12:15:00Z")); + let online = true; + respond = () => reply(directoryPage([{ ...directoryRow("first"), status: online ? "online" : "offline" }])); + try { + const { result } = view(); + await act(async () => { await vi.advanceTimersByTimeAsync(10); }); + expect(result.current.observers[0]?.status).toBe("online"); + online = false; + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(result.current.observers[0]?.status).toBe("offline"); + expect(listingRequests()).toHaveLength(2); + } finally { vi.useRealTimers(); } +}); + +it("uses a fresh rolling window on periodic refresh and replaces all loaded pages atomically", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-10-05T12:15:23.456Z")); + let generation = 0; + let finishSecondPage: (response: Response) => void; + const page = (part: string, end: number) => directoryPage([directoryRow(`${generation}-${part}`, generation ? 200 : 100)], { + windowStart: end - 7 * 86400000, windowEnd: end, + hasMore: part === "first", nextCursor: part === "first" ? 200 : undefined, + maxObservationCount: generation ? 200 : 100, + }); + respond = url => { + const continuation = url.searchParams.has("cursor"); + if (generation && continuation) return new Promise(resolve => { finishSecondPage = resolve; }); + return reply(page(continuation ? "second" : "first", continuation ? Number(url.searchParams.get("until")) : Date.now())); + }; + try { + const { result } = view(); + await act(async () => { await vi.advanceTimersByTimeAsync(10); }); + await act(async () => { await result.current.loadMore(); await vi.advanceTimersByTimeAsync(10); }); + expect(result.current.observers.map(o => o.id)).toEqual(["0-first", "0-second"]); + const oldEnd = result.current.windowEnd; + generation = 1; + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + const refreshFirst = listingRequests().at(-2)!.searchParams; + const refreshNext = listingRequests().at(-1)!.searchParams; + expect(refreshFirst.has("cursor")).toBe(false); + expect(refreshFirst.has("since")).toBe(false); + expect(refreshFirst.has("until")).toBe(false); + expect(Number(refreshNext.get("until"))).toBeGreaterThan(oldEnd!); + expect(Number(refreshNext.get("until")) - Number(refreshNext.get("since"))).toBe(7 * 86400000); + expect(result.current.windowEnd).toBe(oldEnd); + expect(result.current.observers.map(o => o.id)).toEqual(["0-first", "0-second"]); + expect(result.current.maxObservationCount).toBe(100); + await act(async () => { + finishSecondPage!(reply(page("second", Number(refreshNext.get("until"))))); + await vi.advanceTimersByTimeAsync(10); + }); + expect(result.current.observers.map(o => o.id)).toEqual(["1-first", "1-second"]); + expect(result.current.windowEnd).toBe(Number(refreshNext.get("until"))); + expect(result.current.maxObservationCount).toBe(200); + } finally { vi.useRealTimers(); } +}); + +it("ignores a pending old-window continuation when manual refresh obtains a new window", async () => { + let finishOld: (response: Response) => void; + let fresh = false; + respond = url => url.searchParams.has("cursor") ? new Promise(resolve => { finishOld = resolve; }) + : reply(directoryPage([directoryRow(fresh ? "fresh" : "old")], { + windowStart: fresh ? 1790557260123 : 1790557200000, windowEnd: fresh ? 1791162060123 : 1791162000000, + hasMore: !fresh, nextCursor: fresh ? undefined : 200, + })); + const { result } = view(); + await waitFor(() => expect(result.current.observers[0]?.id).toBe("old")); + act(() => { void result.current.loadMore(); }); + await waitFor(() => expect(listingRequests()).toHaveLength(2)); + fresh = true; + await act(async () => { await result.current.refresh(); }); + await waitFor(() => expect(result.current.observers.map(o => o.id)).toEqual(["fresh"])); + await act(async () => finishOld!(reply(directoryPage([directoryRow("stale")])))); + expect(result.current.observers.map(o => o.id)).toEqual(["fresh"]); + expect(result.current.windowEnd).toBe(1791162060123); + expect(Object.fromEntries(listingRequests().at(-1)!.searchParams)).toEqual({ iatas: "YOW", sort: "traffic", limit: "200" }); +}); diff --git a/tests/fixtures/observer-directory.ts b/tests/fixtures/observer-directory.ts new file mode 100644 index 00000000..253f940a --- /dev/null +++ b/tests/fixtures/observer-directory.ts @@ -0,0 +1,14 @@ +import type { ObserverSummary } from "../../src/features/observers/types"; + +export const directoryRow = (id: string, count: number | null = 100) => ({ + id, displayName: `Receiver ${id}`, iata: "YOW", status: "online" as const, observationCount: count, +}); +export function directoryPage(items: (ObserverSummary & { observationCount: number | null })[], extra = {}) { + return { + items, hasMore: false, generatedAt: 1791162000000, + windowStart: 1790557200000, windowEnd: 1791162000000, + sort: "traffic" as const, effectiveSort: "traffic" as const, + coverage: { status: "complete" as const, expectedHours: 168, completeHours: 168, partialHours: 0, missingHours: 0 }, + maxObservationCount: 1000, observerTypes: ["meshcoretomqtt", "RemoteTerm"], ...extra, + }; +}