Search packet hashes server-side with hashPrefix

This commit is contained in:
MrAlders0n
2026-10-14 09:13:50 -04:00
parent 4de2dd0034
commit 0367ee5257
8 changed files with 93 additions and 3 deletions
+2 -1
View File
@@ -97,7 +97,7 @@ function statsRegionParams(r: StatsRegion): { iatas?: string; region?: string }
export function getPackets(
iatas: string[] | undefined,
params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] },
params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[]; hashPrefix?: string },
): Promise<CursorPage<PacketSummary>> {
if (noIatas(iatas)) return Promise.resolve(emptyPage());
return request("/packets", {
@@ -107,6 +107,7 @@ export function getPackets(
payloadTypes: params?.payloadTypes?.length ? params.payloadTypes.join(",") : undefined,
routeTypes: params?.routeTypes?.length ? params.routeTypes.join(",") : undefined,
scopes: params?.scopes?.length ? params.scopes.join(",") : undefined,
hashPrefix: params?.hashPrefix,
});
}
+6
View File
@@ -50,6 +50,12 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF
function handleChange(val: string) {
setLocalValue(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
// clearing should restore the full list right away
if (val === "") {
debounceRef.current = null;
onChange(val);
return;
}
debounceRef.current = setTimeout(() => {
onChange(val);
}, 300);
+1
View File
@@ -16,6 +16,7 @@ export interface PacketServerFilter {
payloadTypes?: number[];
routeTypes?: number[];
scopes?: string[];
hashPrefix?: string;
}
export const EMPTY_FILTERS: PacketFilterState = {
+8 -1
View File
@@ -118,11 +118,18 @@ export function usePacketFilters() {
// The /packets endpoint accepts comma-separated payloadTypes/routeTypes/scopes, so any selected
// dimension goes server-side and pagination pulls the correctly-filtered set from the full history.
// (observers has no server param, so it stays client-side in matchesFilters, as does the live buffer.)
// A 4-16 char hex hash search also goes server-side so older packets can be found.
const HASH_PREFIX_RE = /^[0-9a-f]{4,16}$/i;
export function toServerFilter(filters: PacketFilterState): PacketServerFilter | null {
const serverFilter: PacketServerFilter = {};
if (filters.payloadTypes.length > 0) serverFilter.payloadTypes = filters.payloadTypes;
if (filters.routeTypes.length > 0) serverFilter.routeTypes = filters.routeTypes;
if (filters.scopes.length > 0) serverFilter.scopes = filters.scopes;
const hashSearch = filters.search.trim();
if (filters.searchField === "hash" && HASH_PREFIX_RE.test(hashSearch)) {
serverFilter.hashPrefix = hashSearch.toLowerCase();
}
return Object.keys(serverFilter).length > 0 ? serverFilter : null;
}
@@ -151,7 +158,7 @@ export function matchesFilters(
return false;
}
if (filters.search && filters.searchField === "hash") {
const q = filters.search.toLowerCase();
const q = filters.search.trim().toLowerCase();
if (!packet.packetHash.toLowerCase().includes(q)) {
return false;
}
+9
View File
@@ -51,6 +51,15 @@ describe("getPackets", () => {
expect(url.searchParams.has("scopes")).toBe(false);
expect(url.searchParams.get("cursor")).toBe("100");
expect(url.searchParams.get("limit")).toBe("50");
expect(url.searchParams.has("hashPrefix")).toBe(false);
});
it("forwards hashPrefix", async () => {
const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false });
await getPackets(["YOW"], { hashPrefix: "6ddf62" });
expect(new URL(getUrl()).searchParams.get("hashPrefix")).toBe("6ddf62");
});
});
@@ -18,3 +18,17 @@ describe("compact observer search", () => {
} finally { vi.useRealTimers(); }
});
});
describe("clearing the search", () => {
it("reports an emptied box immediately instead of waiting out the debounce", () => {
vi.useFakeTimers();
try {
const change = vi.fn();
render(<SearchBar value="abcd" onChange={change} fields={[{ value: "hash", label: "Hash" }]} field="hash" onFieldChange={() => {}} hideField inputLabel="Search" />);
fireEvent.change(screen.getByRole("textbox", { name: "Search" }), { target: { value: "" } });
expect(change).toHaveBeenCalledExactlyOnceWith("");
act(() => vi.advanceTimersByTime(300));
expect(change).toHaveBeenCalledOnce();
} finally { vi.useRealTimers(); }
});
});
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { render, screen, fireEvent, within, act } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PacketList } from "../../../src/features/packets/PacketList";
@@ -367,6 +367,34 @@ describe("PacketList selected packet outside the loaded results", () => {
});
});
describe("PacketList hash search", () => {
afterEach(() => {
vi.useRealTimers();
usePackets.mockImplementation(basePackets);
});
it("sends a typed hex hash search server-side once the search box settles", () => {
vi.useFakeTimers();
renderList();
fireEvent.change(screen.getByPlaceholderText("Search by hash..."), { target: { value: "6DDF" } });
expect(usePackets).toHaveBeenLastCalledWith(false, null);
act(() => { vi.runOnlyPendingTimers(); });
expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf" });
});
it("starts with the URL's hash search already server-side", () => {
renderList("/?q=6ddf62");
expect(usePackets).toHaveBeenLastCalledWith(false, { hashPrefix: "6ddf62" });
});
it("still filters live packets client-side while a hash search is active", () => {
usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("6ddf0001"), packet("aa110002")] }));
renderList("/?q=6ddf");
expect(screen.getByRole("button", { name: "6ddf0001" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "aa110002" })).toBeNull();
});
});
describe("PacketList in French", () => {
it("translates the banners and the selected-packet card", async () => {
await i18n.changeLanguage("fr");
@@ -21,6 +21,13 @@ function pkt(over: Partial<PacketSummary>): PacketSummary {
};
}
describe("matchesFilters — hash search", () => {
it("matches a trimmed, case-insensitive substring of the hash", () => {
expect(matchesFilters(pkt({ packetHash: "abcd1234" }), { ...EMPTY_FILTERS, search: " CD12 " })).toBe(true);
expect(matchesFilters(pkt({ packetHash: "abcd1234" }), { ...EMPTY_FILTERS, search: "ffff" })).toBe(false);
});
});
describe("matchesFilters — scope", () => {
it("ignores scope when no scope filter is set", () => {
expect(matchesFilters(pkt({ scope: "#bc" }), EMPTY_FILTERS)).toBe(true);
@@ -83,6 +90,23 @@ describe("toServerFilter", () => {
expect(toServerFilter({ ...EMPTY_FILTERS, observers: ["o1"] })).toBeNull();
expect(toServerFilter({ ...EMPTY_FILTERS, search: "ab" })).toBeNull();
});
it.each(["abcd", "ABCDE", "6ddf62fb5b9a969e", " abcd "])("sends a %j hash search server-side as a trimmed lowercase hashPrefix", (search) => {
expect(toServerFilter({ ...EMPTY_FILTERS, search })).toEqual({ hashPrefix: search.trim().toLowerCase() });
});
it.each(["abc", "6ddf62fb5b9a969e0", "abcg", "ab cd"])("keeps the %j hash search client-side only", (search) => {
expect(toServerFilter({ ...EMPTY_FILTERS, search })).toBeNull();
});
it("never sends a path search as a hashPrefix", () => {
expect(toServerFilter({ ...EMPTY_FILTERS, search: "abcd", searchField: "path" })).toBeNull();
});
it("combines hashPrefix with the other server filters", () => {
expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[], search: "abcd" }))
.toEqual({ payloadTypes: [4], hashPrefix: "abcd" });
});
});
function routerAt(url: string) {