From 2e2b845137746db43d30efe8184fa2cbf8b1fccf Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 14 Jul 2026 22:31:50 -0400 Subject: [PATCH] Filter packet history on the server when a single type, route or scope is selected --- src/api/client.ts | 5 +- src/features/packets/PacketList.tsx | 6 +- src/features/packets/types.ts | 7 ++ src/features/packets/usePacketFilters.ts | 12 +- src/features/packets/usePackets.ts | 15 ++- tests/api/client.test.ts | 29 ++++- tests/features/packets/PacketList.test.tsx | 51 ++++++--- .../features/packets/usePacketFilters.test.ts | 38 ++++++- tests/features/packets/usePackets.test.tsx | 107 ++++++++++++++++++ 9 files changed, 244 insertions(+), 26 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 52a28d2..116ca58 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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> { return request("/packets", { iatas: iatasParam(iatas), cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, + payloadType: params?.payloadType, + routeType: params?.routeType, + scope: params?.scope, }); } diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index ab765ab..f2ee04c 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -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)), diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts index c6e4aa3..e563c10 100644 --- a/src/features/packets/types.ts +++ b/src/features/packets/types.ts @@ -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: [], diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index 261aaa3..5645a6c 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -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( diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index c57611a..f1917c2 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -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, diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a9909e9..0cdb141 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -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", diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 347344a..e57c03b 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -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( + + + , + ); + } + + 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(); diff --git a/tests/features/packets/usePacketFilters.test.ts b/tests/features/packets/usePacketFilters.test.ts index b32bc55..43e4593 100644 --- a/tests/features/packets/usePacketFilters.test.ts +++ b/tests/features/packets/usePacketFilters.test.ts @@ -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 { @@ -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); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index e7dfa23..fe4f765 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -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 }) => ( + {children} + ); + + 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,