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)}
/>
+
+
>
);
}
diff --git a/frontend/src/components/RoomServerPanel.tsx b/frontend/src/components/RoomServerPanel.tsx
index 54f2d6a..875ee52 100644
--- a/frontend/src/components/RoomServerPanel.tsx
+++ b/frontend/src/components/RoomServerPanel.tsx
@@ -246,7 +246,12 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
return (
-
diff --git a/frontend/src/test/rawPacketDetailModal.test.tsx b/frontend/src/test/rawPacketDetailModal.test.tsx
index 8a36404..87bbe32 100644
--- a/frontend/src/test/rawPacketDetailModal.test.tsx
+++ b/frontend/src/test/rawPacketDetailModal.test.tsx
@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
import type { Channel, RawPacket } from '../types';
+vi.mock('../components/ui/sonner', () => ({
+ toast: Object.assign(vi.fn(), {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ }),
+}));
+
+const { toast } = await import('../components/ui/sonner');
+const mockToast = toast as unknown as {
+ success: ReturnType;
+};
+
const BOT_CHANNEL: Channel = {
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
name: '#bot',
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
};
describe('RawPacketDetailModal', () => {
+ it('copies the full packet hex to the clipboard', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.assign(navigator, {
+ clipboard: { writeText },
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
+
+ expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
+ expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
+ });
+
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
render();
diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx
index 759a765..b116a04 100644
--- a/frontend/src/test/rawPacketFeedView.test.tsx
+++ b/frontend/src/test/rawPacketFeedView.test.tsx
@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
});
+ it('analyzes a pasted raw packet without adding it to the live feed', () => {
+ renderView({ channels: [TEST_CHANNEL] });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
+
+ expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
+
+ fireEvent.change(screen.getByLabelText('Packet Hex'), {
+ target: { value: GROUP_TEXT_PACKET_HEX },
+ });
+
+ expect(screen.getByText('Full packet hex')).toBeInTheDocument();
+ expect(screen.getByText('Packet fields')).toBeInTheDocument();
+ expect(screen.getByText('Payload fields')).toBeInTheDocument();
+ });
+
it('shows stats by default on desktop', () => {
vi.stubGlobal(
'matchMedia',