Filter packet history on the server when a single type, route or scope is selected

This commit is contained in:
MrAlders0n
2026-07-14 22:31:50 -04:00
parent 82a83fd33f
commit 2e2b845137
9 changed files with 244 additions and 26 deletions
+4 -1
View File
@@ -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,
});
}
+4 -2
View File
@@ -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)),
+7
View File
@@ -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: [],
+11 -1
View File
@@ -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(
+10 -5
View File
@@ -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,
+28 -1
View File
@@ -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",
+37 -14
View File
@@ -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);
+107
View File
@@ -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,