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 ( +
+
+ onHexFilterChange(event.target.value)} + placeholder="Filter by hex…" + aria-label="Filter loaded packets by hex substring" + className="w-44 rounded border border-input bg-background px-2 py-0.5 pr-6 text-xs" + /> + {hexFilter !== '' && ( + + )} +
+ {hexFilter.trim() !== '' && + (hexInvalid ? ( + Enter hex only + ) : ( + + {matchCount.toLocaleString()} / {totalCount.toLocaleString()} + + ))} + + {KNOWN_PAYLOAD_TYPES.map((type) => ( + + + + + ))} + +
+ ); +} + 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: [