From df0ed8452bf10375dae002e647517109eb34815c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 20 Mar 2026 21:57:07 -0700 Subject: [PATCH] Add BYOPacket analyzer. Closes #98. --- .../src/components/RawPacketDetailModal.tsx | 232 ++++++++++-------- frontend/src/components/RawPacketFeedView.tsx | 119 ++++++++- frontend/src/components/RoomServerPanel.tsx | 7 +- .../src/test/rawPacketDetailModal.test.tsx | 27 ++ frontend/src/test/rawPacketFeedView.test.tsx | 16 ++ 5 files changed, 285 insertions(+), 116 deletions(-) diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx index bfafcb1..4498755 100644 --- a/frontend/src/components/RawPacketDetailModal.tsx +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -8,6 +8,8 @@ import { inspectRawPacketWithOptions, type PacketByteField, } from '../utils/rawPacketInspector'; +import { toast } from './ui/sonner'; +import { Button } from './ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; interface RawPacketDetailModalProps { @@ -16,6 +18,11 @@ interface RawPacketDetailModalProps { onClose: () => void; } +interface RawPacketInspectionPanelProps { + packet: RawPacket; + channels: Channel[]; +} + interface FieldPaletteEntry { box: string; boxActive: string; @@ -500,37 +507,146 @@ function FieldSection({ ); } -export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) { +export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) { const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); const groupTextCandidates = useMemo( () => buildGroupTextResolutionCandidates(channels), [channels] ); const inspection = useMemo( - () => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null), + () => inspectRawPacketWithOptions(packet, decoderOptions), [decoderOptions, packet] ); const [hoveredFieldId, setHoveredFieldId] = useState(null); const packetDisplayFields = useMemo( - () => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []), - [inspection] - ); - const fullPacketFields = useMemo( - () => (inspection ? buildDisplayFields(inspection) : []), + () => inspection.packetFields.filter((field) => field.name !== 'Payload'), [inspection] ); + const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]); const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]); const packetContext = useMemo( - () => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null), + () => getPacketContext(packet, inspection, groupTextCandidates), [groupTextCandidates, inspection, packet] ); const packetIsDecrypted = useMemo( - () => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false), + () => packetShowsDecryptedState(packet, inspection), [inspection, packet] ); - if (!packet || !inspection) { + return ( +
+
+
+
+
+
+ Summary +
+
+ {inspection.summary.summary} +
+
+
+ {formatTimestamp(packet.timestamp)} +
+
+ {packetContext ? ( +
+
+ {packetContext.title} +
+
+ {packetContext.primary} +
+ {packetContext.secondary ? ( +
+ {packetContext.secondary} +
+ ) : null} +
+ ) : null} +
+ +
+ + + +
+
+ + {inspection.validationErrors.length > 0 ? ( +
+
Validation notes
+
+ {inspection.validationErrors.map((error) => ( +
{error}
+ ))} +
+
+ ) : null} + +
+
+
Full packet hex
+ +
+
+ +
+
+ +
+ + + +
+
+ ); +} + +export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) { + if (!packet) { return null; } @@ -543,101 +659,7 @@ export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDet Detailed byte and field breakdown for the selected raw packet. - -
-
-
-
-
-
- Summary -
-
- {inspection.summary.summary} -
-
-
- {formatTimestamp(packet.timestamp)} -
-
- {packetContext ? ( -
-
- {packetContext.title} -
-
- {packetContext.primary} -
- {packetContext.secondary ? ( -
- {packetContext.secondary} -
- ) : null} -
- ) : null} -
- -
- - - -
-
- - {inspection.validationErrors.length > 0 ? ( -
-
Validation notes
-
- {inspection.validationErrors.map((error) => ( -
{error}
- ))} -
-
- ) : null} - -
-
Full packet hex
-
- -
-
- -
- - - -
-
+ ); diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index 6c43850..0ab1f19 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -2,7 +2,9 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { RawPacketList } from './RawPacketList'; -import { RawPacketDetailModal } from './RawPacketDetailModal'; +import { RawPacketDetailModal, RawPacketInspectionPanel } from './RawPacketDetailModal'; +import { Button } from './ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; import type { Channel, Contact, RawPacket } from '../types'; import { RAW_PACKET_STATS_WINDOWS, @@ -371,6 +373,36 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) { ); } +function normalizePacketHex(input: string): string { + return input.replace(/\s+/g, '').toUpperCase(); +} + +function validatePacketHex(input: string): string | null { + if (!input) { + return 'Paste a packet hex string to analyze.'; + } + if (!/^[0-9A-F]+$/.test(input)) { + return 'Packet hex may only contain 0-9 and A-F characters.'; + } + if (input.length % 2 !== 0) { + return 'Packet hex must contain an even number of characters.'; + } + return null; +} + +function buildPastedRawPacket(packetHex: string): RawPacket { + return { + id: -1, + timestamp: Math.floor(Date.now() / 1000), + data: packetHex, + payload_type: 'Unknown', + snr: null, + rssi: null, + decrypted: false, + decrypted_info: null, + }; +} + export function RawPacketFeedView({ packets, rawPacketStatsSession, @@ -385,6 +417,8 @@ export function RawPacketFeedView({ const [selectedWindow, setSelectedWindow] = useState('10m'); const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); const [selectedPacket, setSelectedPacket] = useState(null); + const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false); + const [packetInput, setPacketInput] = useState(''); useEffect(() => { const interval = window.setInterval(() => { @@ -418,6 +452,26 @@ export function RawPacketFeedView({ () => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)), [contacts, stats.newestNeighbors] ); + const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]); + const packetInputError = useMemo( + () => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null), + [normalizedPacketInput] + ); + const analyzedPacket = useMemo( + () => + normalizedPacketInput.length > 0 && packetInputError === null + ? buildPastedRawPacket(normalizedPacketInput) + : null, + [normalizedPacketInput, packetInputError] + ); + + const handleAnalyzeModalChange = (isOpen: boolean) => { + setAnalyzeModalOpen(isOpen); + if (isOpen) { + return; + } + setPacketInput(''); + }; return ( <> @@ -428,15 +482,26 @@ export function RawPacketFeedView({ Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}

- +
+ + +
@@ -604,6 +669,40 @@ export function RawPacketFeedView({ channels={channels} onClose={() => setSelectedPacket(null)} /> + + + + + Analyze Packet + Paste and inspect a raw packet hex string. + +
+
+ +