diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 9fcd2db..55a7285 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -42,7 +42,7 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana const [scope, setScope] = useState(""); const [minimized, setMinimized] = useState(false); const [sort, setSort] = useState("traffic"); - const directory = useObserverDirectory(wsManager, { range, sort, search, status, type, broker, scope }); + 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 = directory.observerTypes; diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx index 17088fc..a7de7e5 100644 --- a/src/features/observers/ObserverSidebar.tsx +++ b/src/features/observers/ObserverSidebar.tsx @@ -46,9 +46,13 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, unsup {onRefresh && !unsupported && } {coverage && coverage.status !== "complete" &&

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

} - {windowEnd != null &&

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

} + {windowEnd != null &&
+

{t("observerPage.directoryPeriod")}

+

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

+
} {unsupported ?

{t("observerPage.upgradeRequired")}

: isPending ?
{t("common.loading")}
diff --git a/src/features/observers/useObserverDirectory.ts b/src/features/observers/useObserverDirectory.ts index 3393d8b..5ead4a1 100644 --- a/src/features/observers/useObserverDirectory.ts +++ b/src/features/observers/useObserverDirectory.ts @@ -2,16 +2,13 @@ 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 { useTick } from "../../hooks/useTick"; import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; -import { rolledWindow, useStatsRegion } from "../stats/useStats"; -import type { StatsRange } from "../stats/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"; export interface ObserverDirectoryOptions { - range: StatsRange; sort: ObserverDirectorySort; search: string; status: string; @@ -26,8 +23,6 @@ const retryDelay = (attempt: number, error: Error) => export function useObserverDirectory(wsManager: WsManager, options: ObserverDirectoryOptions) { const { where, regionKey, isResolved } = useStatsRegion(); - const now = useTick(60_000); - const { since, until } = rolledWindow(options.range, now); const { sort, search, status, type, broker, scope } = options; const queryClient = useQueryClient(); const capability = useQuery({ @@ -37,14 +32,15 @@ export function useObserverDirectory(wsManager: WsManager, options: ObserverDire staleTime: 60_000, retryDelay, }); - const queryKey = useMemo(() => ["observer-directory", API_BASE, regionKey, since, until, sort, search, status, type, broker, scope], - [regionKey, since, until, sort, search, status, type, broker, scope]); + 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, since, until, sort, name: search || undefined, status: status || undefined, + 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]; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 57f70b0..f864827 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -333,6 +333,8 @@ } }, "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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c418dd9..03ee2a6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -341,6 +341,8 @@ } }, "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", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 3468463..86178e4 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -333,6 +333,8 @@ } }, "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", diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index 30d3d19..30127fe 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -5,6 +5,7 @@ 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,12 +13,12 @@ const directory: ObserverSummary[] = [ { id: "observer-b", displayName: "Basement B", iata: "YOW", status: "offline", observerType: "RemoteTerm" }, ]; vi.mock("../../../src/features/observers/useObserverDirectory", () => ({ - useObserverDirectory: (_ws: unknown, options: { search?: string } | string) => { + 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([]) })); @@ -180,3 +181,15 @@ it("preserves server-filtered wildcard results instead of filtering the loaded p 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 002d38b..a7ebe22 100644 --- a/tests/features/observers/ObserverSidebar.test.tsx +++ b/tests/features/observers/ObserverSidebar.test.tsx @@ -82,3 +82,17 @@ it("shows upgrade and retry, loading, and empty-filter states", () => { rerender(); expect(screen.getByText("No matches")).toBeInTheDocument(); }); + + +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 index 01cf4f3..ca91c99 100644 --- a/tests/features/observers/useObserverDirectory.test.tsx +++ b/tests/features/observers/useObserverDirectory.test.tsx @@ -11,7 +11,7 @@ const region = { regionKey: "YOW", iatas: ["YOW"], isResolved: true, emptyRegion 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 = { range: "7d", sort: "traffic", search: "", status: "", type: "", broker: "", scope: "" }; +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[]; @@ -58,16 +58,13 @@ it("loads only the first page initially, then returns later counts beyond 200 wi expect(result.current.hasNextPage).toBe(false); }); -it("sends every filter and a complete seven-day rollup window on the first request", async () => { +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" }); - const until = Number(p.get("until")); - expect(until - Number(p.get("since"))).toBe(7 * 86400000); - expect(until % 3600000).toBe(0); - expect(Date.now() - until).toBeGreaterThanOrEqual(35 * 60000); - expect(Date.now() - until).toBeLessThan(95 * 60000); + 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 () => { @@ -210,16 +207,17 @@ it("repeats every filter on continuation using the response window, then clears expect(listingRequests().at(-1)?.searchParams.has("cursor")).toBe(false); }); -it("starts a new first page on region and range changes", async () => { +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, range: "30d" }); + 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(Number(p.get("until")) - Number(p.get("since"))).toBe(30 * 86400000); + 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 => { @@ -244,3 +242,67 @@ it("refreshes loaded rows each minute so fetched online status does not stay gre 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" }); +});