From 387c9b0e0a5a9e98524903912565744a5afe8a5c Mon Sep 17 00:00:00 2001
From: jkingsman
Date: Thu, 9 Jul 2026 14:56:31 -0700
Subject: [PATCH] Add packet search to raw feed. Closes #320.
---
frontend/src/components/RawPacketFeedView.tsx | 259 ++++++++++++------
frontend/src/test/rawPacketFeedView.test.tsx | 64 +++++
2 files changed, 239 insertions(+), 84 deletions(-)
diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx
index 43f13bf..a6af44a 100644
--- a/frontend/src/components/RawPacketFeedView.tsx
+++ b/frontend/src/components/RawPacketFeedView.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import {
BarChart,
Bar,
@@ -69,6 +69,133 @@ function getPacketTypeName(
}
}
+/**
+ * Normalize a raw-hex filter query. Lowercases, strips whitespace, `:`
+ * separators, and a leading `0x` so a pasted key prefix like `A1B2C3`,
+ * `a1:b2:c3`, or `0xa1b2` all match. Returns `invalid: true` when non-hex
+ * characters remain after cleaning so the UI can hint instead of silently
+ * showing zero results. Matches against `RawPacket.data` (stored lowercase hex).
+ */
+function normalizeHexQuery(raw: string): { query: string; invalid: boolean } {
+ const cleaned = raw
+ .trim()
+ .toLowerCase()
+ .replace(/^0x/, '')
+ .replace(/[\s:]+/g, '');
+ if (cleaned === '') return { query: '', invalid: false };
+ return { query: cleaned, invalid: !/^[0-9a-f]+$/.test(cleaned) };
+}
+
+interface FeedFilterControlsProps {
+ className?: string;
+ allTypesEnabled: boolean;
+ enabledTypes: Set;
+ onToggleAll: () => void;
+ onToggleType: (type: string) => void;
+ onOnly: (type: string) => void;
+ autoScroll: boolean;
+ onAutoScrollChange: (checked: boolean) => void;
+ hexFilter: string;
+ onHexFilterChange: (value: string) => void;
+ hexInvalid: boolean;
+ matchCount: number;
+ totalCount: number;
+}
+
+/**
+ * The feed filter bar: hex substring filter, payload-type checkboxes, and the
+ * autoscroll toggle. Rendered twice (mobile + desktop) with only the display
+ * classes differing, so the control set lives here to stay in sync. Display is
+ * driven entirely by `className` (no base `flex`) to avoid a Tailwind
+ * `flex`/`hidden` conflict.
+ */
+function FeedFilterControls({
+ className,
+ allTypesEnabled,
+ enabledTypes,
+ onToggleAll,
+ onToggleType,
+ onOnly,
+ autoScroll,
+ onAutoScrollChange,
+ hexFilter,
+ onHexFilterChange,
+ hexInvalid,
+ matchCount,
+ totalCount,
+}: FeedFilterControlsProps) {
+ return (
+
+ );
+}
+
interface RawPacketFeedViewProps {
packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState;
@@ -480,6 +607,8 @@ export function RawPacketFeedView({
const [enabledTypes, setEnabledTypes] = useState>(() => new Set(KNOWN_PAYLOAD_TYPES));
// Autoscroll defaults on; intentionally not persisted across refreshes.
const [autoScroll, setAutoScroll] = useState(true);
+ // Raw-hex substring filter over the in-memory feed buffer (session-only).
+ const [hexFilter, setHexFilter] = useState('');
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
@@ -494,12 +623,24 @@ export function RawPacketFeedView({
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
+ const { query: hexQuery, invalid: hexInvalid } = useMemo(
+ () => normalizeHexQuery(hexFilter),
+ [hexFilter]
+ );
+
const filteredPackets = useMemo(() => {
- if (allTypesEnabled) return packets;
+ // A non-hex query matches nothing; the input surfaces a hint instead.
+ if (hexInvalid) return [];
+ // Fast path: no filters active.
+ if (allTypesEnabled && hexQuery === '') return packets;
return packetsWithTypes
- .filter(({ payloadType }) => enabledTypes.has(payloadType))
+ .filter(
+ ({ packet, payloadType }) =>
+ (allTypesEnabled || enabledTypes.has(payloadType)) &&
+ (hexQuery === '' || packet.data.toLowerCase().includes(hexQuery))
+ )
.map(({ packet }) => packet);
- }, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
+ }, [packetsWithTypes, enabledTypes, packets, allTypesEnabled, hexQuery, hexInvalid]);
const handleToggleAll = () => {
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
@@ -610,88 +751,38 @@ export function RawPacketFeedView({
{mobileFiltersOpen && (
-
-
- {KNOWN_PAYLOAD_TYPES.map((type) => (
-
-
-
-
- ))}
-
-
+
)}
-
-
- {KNOWN_PAYLOAD_TYPES.map((type) => (
-
-
-
-
- ))}
-
-
+
diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx
index f987c83..06078c1 100644
--- a/frontend/src/test/rawPacketFeedView.test.tsx
+++ b/frontend/src/test/rawPacketFeedView.test.tsx
@@ -376,6 +376,70 @@ describe('RawPacketFeedView', () => {
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
});
+ describe('hex filter', () => {
+ function makePacket(id: number, data: string): RawPacket {
+ return {
+ id,
+ observation_id: id,
+ timestamp: 1_700_000_000 + id,
+ data,
+ decrypted: false,
+ payload_type: 'Unknown',
+ rssi: null,
+ snr: null,
+ decrypted_info: null,
+ };
+ }
+
+ const HEX_FILTER_LABEL = 'Filter loaded packets by hex substring';
+
+ it('filters the feed to packets whose raw hex contains the query', () => {
+ renderView({ packets: [makePacket(1, 'aa11bb22'), makePacket(2, 'cc33dd44')] });
+
+ expect(screen.getByText('AA11BB22')).toBeInTheDocument();
+ expect(screen.getByText('CC33DD44')).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText(HEX_FILTER_LABEL), { target: { value: 'aa11' } });
+
+ expect(screen.getByText('AA11BB22')).toBeInTheDocument();
+ expect(screen.queryByText('CC33DD44')).not.toBeInTheDocument();
+ expect(screen.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
+ });
+
+ it('normalizes 0x prefix, colons, whitespace, and case in the query', () => {
+ renderView({ packets: [makePacket(1, 'aabbcc'), makePacket(2, 'ddeeff')] });
+
+ fireEvent.change(screen.getByLabelText(HEX_FILTER_LABEL), {
+ target: { value: '0xAA:BB CC' },
+ });
+
+ expect(screen.getByText('AABBCC')).toBeInTheDocument();
+ expect(screen.queryByText('DDEEFF')).not.toBeInTheDocument();
+ });
+
+ it('shows a hint and matches nothing for a non-hex query', () => {
+ renderView({ packets: [makePacket(1, 'aabbcc')] });
+
+ fireEvent.change(screen.getByLabelText(HEX_FILTER_LABEL), { target: { value: 'zzz' } });
+
+ expect(screen.getByText('Enter hex only')).toBeInTheDocument();
+ expect(screen.queryByText('AABBCC')).not.toBeInTheDocument();
+ expect(screen.getByText(/No packets received yet/i)).toBeInTheDocument();
+ });
+
+ it('restores the full feed when the filter is cleared', () => {
+ renderView({ packets: [makePacket(1, 'aabbcc'), makePacket(2, 'ddeeff')] });
+
+ fireEvent.change(screen.getByLabelText(HEX_FILTER_LABEL), { target: { value: 'aa' } });
+ expect(screen.queryByText('DDEEFF')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear hex filter' }));
+
+ expect(screen.getByText('AABBCC')).toBeInTheDocument();
+ expect(screen.getByText('DDEEFF')).toBeInTheDocument();
+ });
+ });
+
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
renderView({
packets: [