mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Add packet search to raw feed. Closes #320.
This commit is contained in:
@@ -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<string>;
|
||||
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 (
|
||||
<div className={cn('mt-1.5 flex-wrap items-center gap-x-3 gap-y-1', className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={hexFilter}
|
||||
onChange={(event) => 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 !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHexFilterChange('')}
|
||||
aria-label="Clear hex filter"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hexFilter.trim() !== '' &&
|
||||
(hexInvalid ? (
|
||||
<span className="text-[0.6875rem] text-warning">Enter hex only</span>
|
||||
) : (
|
||||
<span className="text-[0.6875rem] text-muted-foreground tabular-nums">
|
||||
{matchCount.toLocaleString()} / {totalCount.toLocaleString()}
|
||||
</span>
|
||||
))}
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={onToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => onToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => onOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(event) => onAutoScrollChange(event.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Autoscroll
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
@@ -480,6 +607,8 @@ export function RawPacketFeedView({
|
||||
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => 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({
|
||||
</p>
|
||||
|
||||
{mobileFiltersOpen && (
|
||||
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Autoscroll
|
||||
</label>
|
||||
</div>
|
||||
<FeedFilterControls
|
||||
className="flex md:hidden"
|
||||
allTypesEnabled={allTypesEnabled}
|
||||
enabledTypes={enabledTypes}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleType={handleToggleType}
|
||||
onOnly={handleOnly}
|
||||
autoScroll={autoScroll}
|
||||
onAutoScrollChange={setAutoScroll}
|
||||
hexFilter={hexFilter}
|
||||
onHexFilterChange={setHexFilter}
|
||||
hexInvalid={hexInvalid}
|
||||
matchCount={filteredPackets.length}
|
||||
totalCount={packets.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allTypesEnabled}
|
||||
onChange={handleToggleAll}
|
||||
className="rounded"
|
||||
/>
|
||||
All
|
||||
</label>
|
||||
{KNOWN_PAYLOAD_TYPES.map((type) => (
|
||||
<span key={type} className="inline-flex items-center gap-1 text-xs">
|
||||
<label className="flex items-center gap-1 text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledTypes.has(type)}
|
||||
onChange={() => handleToggleType(type)}
|
||||
className="rounded"
|
||||
/>
|
||||
{type}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
|
||||
onClick={() => handleOnly(type)}
|
||||
>
|
||||
(only)
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<label className="ml-auto flex items-center gap-1 text-xs text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Autoscroll
|
||||
</label>
|
||||
</div>
|
||||
<FeedFilterControls
|
||||
className="hidden md:flex"
|
||||
allTypesEnabled={allTypesEnabled}
|
||||
enabledTypes={enabledTypes}
|
||||
onToggleAll={handleToggleAll}
|
||||
onToggleType={handleToggleType}
|
||||
onOnly={handleOnly}
|
||||
autoScroll={autoScroll}
|
||||
onAutoScrollChange={setAutoScroll}
|
||||
hexFilter={hexFilter}
|
||||
onHexFilterChange={setHexFilter}
|
||||
hexInvalid={hexInvalid}
|
||||
matchCount={filteredPackets.length}
|
||||
totalCount={packets.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user