fix(observers): rank available traffic over rolling seven days

This commit is contained in:
MrAlders0n
2026-10-05 08:40:59 -04:00
parent 97fff7aa71
commit a01ddcf4fb
9 changed files with 119 additions and 24 deletions
+1 -1
View File
@@ -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;
+6 -2
View File
@@ -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];
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+15 -2
View File
@@ -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" });
});