mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 00:58:21 +00:00
Filter packet history on the server when a single type, route or scope is selected
This commit is contained in:
+4
-1
@@ -58,12 +58,15 @@ function iatasParam(iatas?: string[]): string | undefined {
|
||||
|
||||
export function getPackets(
|
||||
iatas: string[] | undefined,
|
||||
params?: { cursor?: number; limit?: number },
|
||||
params?: { cursor?: number; limit?: number; payloadType?: number; routeType?: number; scope?: string },
|
||||
): Promise<CursorPage<PacketSummary>> {
|
||||
return request("/packets", {
|
||||
iatas: iatasParam(iatas),
|
||||
cursor: params?.cursor,
|
||||
limit: params?.limit ?? DEFAULT_PAGE_SIZE,
|
||||
payloadType: params?.payloadType,
|
||||
routeType: params?.routeType,
|
||||
scope: params?.scope,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { usePackets } from "./usePackets";
|
||||
import { usePacketFilters, matchesFilters } from "./usePacketFilters";
|
||||
import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters";
|
||||
import { useScopes } from "../../hooks/useScopes";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers";
|
||||
@@ -32,6 +32,8 @@ interface PacketListProps {
|
||||
export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters();
|
||||
// single-value selections go to the server so scrolling pages through matching history
|
||||
const serverFilter = useMemo(() => toServerFilter(filters), [filters]);
|
||||
const scopeNames = useScopes();
|
||||
const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]);
|
||||
const { regionKey } = useRegion();
|
||||
@@ -55,7 +57,7 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) {
|
||||
handleLagged,
|
||||
laggedCount,
|
||||
dismissLagged,
|
||||
} = usePackets(!isAtTop);
|
||||
} = usePackets(!isAtTop, serverFilter);
|
||||
|
||||
const packets = useMemo(
|
||||
() => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)),
|
||||
|
||||
@@ -11,6 +11,13 @@ export interface PacketFilterState {
|
||||
searchField: SearchField;
|
||||
}
|
||||
|
||||
// Filters /packets history can apply server-side (each accepts a single value per request)
|
||||
export interface PacketServerFilter {
|
||||
payloadType?: number;
|
||||
routeType?: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: PacketFilterState = {
|
||||
payloadTypes: [],
|
||||
routeTypes: [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import type { PacketFilterState, SearchField } from "./types";
|
||||
import type { PacketFilterState, PacketServerFilter, SearchField } from "./types";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums";
|
||||
|
||||
@@ -115,6 +115,16 @@ export function usePacketFilters() {
|
||||
return { filters, setFilter, setSearch, setSearchField, clearFilters };
|
||||
}
|
||||
|
||||
// The /packets endpoint filters by a single payloadType/routeType/scope per request, so a
|
||||
// dimension only goes server-side when exactly one value is selected; the rest stay client-side.
|
||||
export function toServerFilter(filters: PacketFilterState): PacketServerFilter | null {
|
||||
const serverFilter: PacketServerFilter = {};
|
||||
if (filters.payloadTypes.length === 1) serverFilter.payloadType = filters.payloadTypes[0]!;
|
||||
if (filters.routeTypes.length === 1) serverFilter.routeType = filters.routeTypes[0]!;
|
||||
if (filters.scopes.length === 1) serverFilter.scope = filters.scopes[0]!;
|
||||
return Object.keys(serverFilter).length > 0 ? serverFilter : null;
|
||||
}
|
||||
|
||||
// client-side filter predicate for packet rows
|
||||
|
||||
export function matchesFilters(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getPackets } from "../../api/client";
|
||||
import { useRegion } from "../../hooks/useRegion";
|
||||
import type { WsPacketObservation, WsLagged } from "../../types/ws";
|
||||
import type { PacketSummary } from "../../types/api";
|
||||
import type { PacketServerFilter } from "./types";
|
||||
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants";
|
||||
|
||||
// merge and deduplicate live + paginated packets
|
||||
@@ -114,7 +115,7 @@ class LivePacketStore {
|
||||
|
||||
// combines live WS stream with paginated history
|
||||
|
||||
export function usePackets(frozen: boolean = false) {
|
||||
export function usePackets(frozen: boolean = false, serverFilter: PacketServerFilter | null = null) {
|
||||
const { iatas, regionKey } = useRegion();
|
||||
const queryClient = useQueryClient();
|
||||
const [store] = useState(() => new LivePacketStore());
|
||||
@@ -167,7 +168,8 @@ export function usePackets(frozen: boolean = false) {
|
||||
);
|
||||
|
||||
// Reset (drop to one fresh first page) instead of invalidate: an invalidate replays every cached
|
||||
// page sequentially — up to 20 requests per lag notice during a flood.
|
||||
// page sequentially — up to 20 requests per lag notice during a flood. The 2-element key matches
|
||||
// filtered variants by prefix, so those reset too.
|
||||
const handleLagged = useCallback(
|
||||
(data: WsLagged) => {
|
||||
setLaggedCount((prev) => prev + data.droppedCount);
|
||||
@@ -177,7 +179,8 @@ export function usePackets(frozen: boolean = false) {
|
||||
);
|
||||
|
||||
// The WS handler is down whenever this tab is unmounted, so cached history may hide a gap right
|
||||
// where the live buffer begins. Refresh the first page on mount to close it.
|
||||
// where the live buffer begins. Refresh the first page on mount to close it (prefix match:
|
||||
// filtered variants included).
|
||||
useEffect(() => {
|
||||
queryClient.resetQueries({ queryKey: ["packets", regionKey] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount only; region changes refetch via the key
|
||||
@@ -191,9 +194,11 @@ export function usePackets(frozen: boolean = false) {
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: ["packets", regionKey],
|
||||
// The unfiltered key must stay 2-element so its cached entry survives filter toggling; the
|
||||
// lagged/mount resets above match both shapes by prefix.
|
||||
queryKey: serverFilter ? ["packets", regionKey, serverFilter] : ["packets", regionKey],
|
||||
// first load and every scroll page are the default 50; getPackets fills in the limit
|
||||
queryFn: ({ pageParam }) => getPackets(iatas, { cursor: pageParam }),
|
||||
queryFn: ({ pageParam }) => getPackets(iatas, { cursor: pageParam, ...serverFilter }),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
initialPageParam: undefined as number | undefined,
|
||||
staleTime: Infinity,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client";
|
||||
import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client";
|
||||
import type { NodeSummary } from "../../src/features/nodes/types";
|
||||
import type { ObserverSummary } from "../../src/features/observers/types";
|
||||
import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types";
|
||||
@@ -22,6 +22,33 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("getPackets", () => {
|
||||
it("forwards the single-value server filters (routeType 0 survives, scope is encoded)", async () => {
|
||||
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
|
||||
|
||||
await getPackets(["YOW"], { payloadType: 4, routeType: 0, scope: "#bc" });
|
||||
|
||||
const url = getUrl();
|
||||
expect(url).toContain("/packets");
|
||||
expect(url).toContain("payloadType=4");
|
||||
expect(url).toContain("routeType=0");
|
||||
expect(url).toContain("scope=%23bc");
|
||||
});
|
||||
|
||||
it("omits the filter params when none are given", async () => {
|
||||
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
|
||||
|
||||
await getPackets(["YOW"], { cursor: 100 });
|
||||
|
||||
const url = getUrl();
|
||||
expect(url).not.toContain("payloadType=");
|
||||
expect(url).not.toContain("routeType=");
|
||||
expect(url).not.toContain("scope=");
|
||||
expect(url).toContain("cursor=100");
|
||||
expect(url).toContain("limit=50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNodesPage", () => {
|
||||
const node: NodeSummary = {
|
||||
id: "n1",
|
||||
|
||||
@@ -4,21 +4,22 @@ import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import { PacketList } from "../../../src/features/packets/PacketList";
|
||||
import type { WsManager } from "../../../src/api/ws-manager";
|
||||
|
||||
const usePackets = vi.fn(() => ({
|
||||
allPackets: [],
|
||||
observerOptions: [],
|
||||
newPacketCount: 0,
|
||||
acknowledgeNewPackets: () => {},
|
||||
fetchNextPage: () => {},
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
observersByHash: new Map(),
|
||||
handlePacketObservation: () => {},
|
||||
handleLagged: () => {},
|
||||
laggedCount: 0,
|
||||
dismissLagged: () => {},
|
||||
}));
|
||||
vi.mock("../../../src/features/packets/usePackets", () => ({
|
||||
usePackets: () => ({
|
||||
allPackets: [],
|
||||
observerOptions: [],
|
||||
newPacketCount: 0,
|
||||
acknowledgeNewPackets: () => {},
|
||||
fetchNextPage: () => {},
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
observersByHash: new Map(),
|
||||
handlePacketObservation: () => {},
|
||||
handleLagged: () => {},
|
||||
laggedCount: 0,
|
||||
dismissLagged: () => {},
|
||||
}),
|
||||
usePackets: (...args: unknown[]) => usePackets(...(args as [])),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] }));
|
||||
@@ -67,6 +68,28 @@ function ExternalHashCloser() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("PacketList server filter wiring", () => {
|
||||
function renderAt(url: string) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<PacketList wsManager={{} as unknown as WsManager} onAnalyze={vi.fn()} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
it("passes a single selected type to usePackets as the server filter", () => {
|
||||
usePackets.mockClear();
|
||||
renderAt("/?types=4");
|
||||
expect(usePackets).toHaveBeenLastCalledWith(false, { payloadType: 4 });
|
||||
});
|
||||
|
||||
it("passes null for multi-select so history stays unfiltered", () => {
|
||||
usePackets.mockClear();
|
||||
renderAt("/?types=2,4");
|
||||
expect(usePackets).toHaveBeenLastCalledWith(false, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PacketList expanded row", () => {
|
||||
it("follows the ?hash param so an external analyzer close deselects the row", () => {
|
||||
const onAnalyze = vi.fn();
|
||||
|
||||
@@ -2,9 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { matchesFilters, usePacketFilters } from "../../../src/features/packets/usePacketFilters";
|
||||
import { matchesFilters, toServerFilter, usePacketFilters } from "../../../src/features/packets/usePacketFilters";
|
||||
import { EMPTY_FILTERS } from "../../../src/features/packets/types";
|
||||
import type { PayloadTypeValue } from "../../../src/types/enums";
|
||||
import type { PayloadTypeValue, RouteTypeValue } from "../../../src/types/enums";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
|
||||
function pkt(over: Partial<PacketSummary>): PacketSummary {
|
||||
@@ -48,6 +48,40 @@ describe("matchesFilters — scope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toServerFilter", () => {
|
||||
it("returns null when nothing narrows to a single value", () => {
|
||||
expect(toServerFilter(EMPTY_FILTERS)).toBeNull();
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [2, 4] as PayloadTypeValue[] })).toBeNull();
|
||||
});
|
||||
|
||||
it("emits payloadType only for a single selected type", () => {
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[] })).toEqual({ payloadType: 4 });
|
||||
});
|
||||
|
||||
it("emits routeType 0 (falsy) for a single selected route", () => {
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, routeTypes: [0] as RouteTypeValue[] })).toEqual({ routeType: 0 });
|
||||
});
|
||||
|
||||
it("emits scope for a single selected scope", () => {
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, scopes: ["#bc"] })).toEqual({ scope: "#bc" });
|
||||
});
|
||||
|
||||
it("emits only the single-valued dimensions when combined", () => {
|
||||
const filters = {
|
||||
...EMPTY_FILTERS,
|
||||
payloadTypes: [4] as PayloadTypeValue[],
|
||||
routeTypes: [1, 2] as RouteTypeValue[],
|
||||
scopes: ["#bc"],
|
||||
};
|
||||
expect(toServerFilter(filters)).toEqual({ payloadType: 4, scope: "#bc" });
|
||||
});
|
||||
|
||||
it("ignores client-only filters (observers, search)", () => {
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, observers: ["o1"] })).toBeNull();
|
||||
expect(toServerFilter({ ...EMPTY_FILTERS, search: "ab" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function routerAt(url: string) {
|
||||
return ({ children }: { children: ReactNode }) =>
|
||||
createElement(MemoryRouter, { initialEntries: [url] }, children);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import { usePackets } from "../../../src/features/packets/usePackets";
|
||||
import type { PacketServerFilter } from "../../../src/features/packets/types";
|
||||
import type { PacketSummary } from "../../../src/types/api";
|
||||
import type { WsPacketObservation } from "../../../src/types/ws";
|
||||
|
||||
@@ -86,6 +87,112 @@ describe("usePackets gap healing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePackets server filter", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
getPackets.mockReset();
|
||||
getPackets.mockResolvedValue({ items: [packet("fresh")], nextCursor: 999 });
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("fetches filtered history under its own query key, leaving the unfiltered entry alone", async () => {
|
||||
renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
|
||||
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
|
||||
expect(getPackets.mock.calls[0]![0]).toEqual(["YOW"]);
|
||||
expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 });
|
||||
await waitFor(() => {
|
||||
expect(qc.getQueryData(["packets", "YOW", { payloadType: 4 }])).toBeDefined();
|
||||
});
|
||||
expect(qc.getQueryData(["packets", "YOW"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the filter on subsequent pages", async () => {
|
||||
const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
expect(getPackets).toHaveBeenCalledTimes(2);
|
||||
expect(getPackets.mock.calls[1]![1]).toEqual({ cursor: 999, payloadType: 4 });
|
||||
});
|
||||
|
||||
it("reuses the cached unfiltered pages when the filter clears (no refetch)", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter),
|
||||
{ initialProps: { filter: null as PacketServerFilter | null }, wrapper },
|
||||
);
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ filter: { payloadType: 4 } });
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(2));
|
||||
|
||||
rerender({ filter: null });
|
||||
await waitFor(() => expect(result.current.allPackets.length).toBeGreaterThan(0));
|
||||
expect(getPackets).toHaveBeenCalledTimes(2); // staleTime Infinity + unchanged unfiltered key
|
||||
});
|
||||
|
||||
it("keeps the live buffer when the filter changes", async () => {
|
||||
const rafCallbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ filter }: { filter: PacketServerFilter | null }) => usePackets(false, filter),
|
||||
{ initialProps: { filter: null as PacketServerFilter | null }, wrapper },
|
||||
);
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.handlePacketObservation(observation("p1"));
|
||||
rafCallbacks.splice(0).forEach((cb) => cb(0));
|
||||
});
|
||||
expect(result.current.newPacketCount).toBe(1);
|
||||
|
||||
rerender({ filter: { payloadType: 4 } });
|
||||
|
||||
expect(result.current.allPackets.map((p) => p.packetHash)).toContain("p1");
|
||||
expect(result.current.newPacketCount).toBe(1); // store untouched by the key switch
|
||||
});
|
||||
|
||||
it("lag reset collapses the filtered entry and refetches with the filter", async () => {
|
||||
const { result } = renderHook(() => usePackets(false, { payloadType: 4 }), { wrapper });
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1));
|
||||
|
||||
qc.setQueryData(["packets", "YOW", { payloadType: 4 }], {
|
||||
pages: [
|
||||
{ items: [packet("a1")], nextCursor: 200 },
|
||||
{ items: [packet("b2")], nextCursor: 100 },
|
||||
{ items: [packet("c3")], nextCursor: null },
|
||||
],
|
||||
pageParams: [undefined, 200, 100],
|
||||
});
|
||||
getPackets.mockClear();
|
||||
result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 });
|
||||
|
||||
await waitFor(() => expect(getPackets).toHaveBeenCalled());
|
||||
await waitFor(() => {
|
||||
const data = qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW", { payloadType: 4 }]);
|
||||
expect(data?.pages).toHaveLength(1);
|
||||
});
|
||||
expect(getPackets.mock.calls[0]![1]).toEqual({ cursor: undefined, payloadType: 4 });
|
||||
});
|
||||
});
|
||||
|
||||
function observation(hash: string): WsPacketObservation["data"] {
|
||||
return {
|
||||
packetHash: hash,
|
||||
|
||||
Reference in New Issue
Block a user