mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-16 18:00:02 +00:00
fix(observers): rank available traffic over rolling seven days
This commit is contained in:
@@ -42,7 +42,7 @@ export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsMana
|
||||
const [scope, setScope] = useState("");
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [sort, setSort] = useState<ObserverDirectorySort>("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;
|
||||
|
||||
@@ -46,9 +46,13 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, unsup
|
||||
{onRefresh && !unsupported && <button type="button" onClick={onRefresh} disabled={isPending || isFetchingNextPage}
|
||||
className="mb-2 self-end px-3 font-mono text-[10px] text-primary disabled:opacity-50">{t("observerPage.refresh")}</button>}
|
||||
{coverage && coverage.status !== "complete" && <p className={messageClass}>
|
||||
{t(effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
|
||||
{t(maxObservationCount != null ? "observerPage.countsPartial"
|
||||
: effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}
|
||||
</p>}
|
||||
{windowEnd != null && <p className="shrink-0 px-3 pb-2 font-mono text-[10px] text-text-dim">{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}</p>}
|
||||
{windowEnd != null && <div className="shrink-0 px-3 pb-2 font-mono text-[10px] text-text-dim">
|
||||
<p>{t("observerPage.directoryPeriod")}</p>
|
||||
<p>{t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}</p>
|
||||
</div>}
|
||||
{unsupported ? <div className={messageClass}><p>{t("observerPage.upgradeRequired")}</p>
|
||||
<button type="button" onClick={onRetry} className="mt-2 text-primary">{t("observerPage.retry")}</button></div>
|
||||
: isPending ? <div className={messageClass}>{t("common.loading")}</div>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -82,3 +82,17 @@ it("shows upgrade and retry, loading, and empty-filter states", () => {
|
||||
rerender(<ObserverSidebar {...props} observers={[]} filtered hasNextPage={false} />);
|
||||
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(<ObserverSidebar {...props} observers={props.observers.slice(0, 3)} effectiveSort="traffic"
|
||||
windowEnd={1791162000000} coverage={{ status, expectedHours: 168, completeHours: 0, missingHours: 168, partialHours: 0 }} />);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user