diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 9f865e8..259f258 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -185,6 +185,7 @@ export function ConversationPane({ packets={rawPackets} rawPacketStatsSession={rawPacketStatsSession} contacts={contacts} + channels={channels} /> ); } diff --git a/frontend/src/components/RawPacketDetailModal.tsx b/frontend/src/components/RawPacketDetailModal.tsx new file mode 100644 index 0000000..da05ffe --- /dev/null +++ b/frontend/src/components/RawPacketDetailModal.tsx @@ -0,0 +1,594 @@ +import { useMemo, useState } from 'react'; +import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder'; + +import type { Channel, RawPacket } from '../types'; +import { cn } from '@/lib/utils'; +import { + createDecoderOptions, + inspectRawPacketWithOptions, + type PacketByteField, +} from '../utils/rawPacketInspector'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; + +interface RawPacketDetailModalProps { + packet: RawPacket | null; + channels: Channel[]; + onClose: () => void; +} + +interface FieldPaletteEntry { + box: string; + boxActive: string; + hex: string; + hexActive: string; +} + +interface GroupTextResolutionCandidate { + key: string; + name: string; + hash: string; +} + +const FIELD_PALETTE: FieldPaletteEntry[] = [ + { + box: 'border-sky-500/30 bg-sky-500/10', + boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20', + hex: 'border-sky-500/40 bg-sky-500/20', + hexActive: 'border-sky-600 bg-sky-500/40', + }, + { + box: 'border-emerald-500/30 bg-emerald-500/10', + boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20', + hex: 'border-emerald-500/40 bg-emerald-500/20', + hexActive: 'border-emerald-600 bg-emerald-500/40', + }, + { + box: 'border-amber-500/30 bg-amber-500/10', + boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20', + hex: 'border-amber-500/40 bg-amber-500/20', + hexActive: 'border-amber-600 bg-amber-500/40', + }, + { + box: 'border-rose-500/30 bg-rose-500/10', + boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20', + hex: 'border-rose-500/40 bg-rose-500/20', + hexActive: 'border-rose-600 bg-rose-500/40', + }, + { + box: 'border-violet-500/30 bg-violet-500/10', + boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20', + hex: 'border-violet-500/40 bg-violet-500/20', + hexActive: 'border-violet-600 bg-violet-500/40', + }, + { + box: 'border-cyan-500/30 bg-cyan-500/10', + boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20', + hex: 'border-cyan-500/40 bg-cyan-500/20', + hexActive: 'border-cyan-600 bg-cyan-500/40', + }, + { + box: 'border-lime-500/30 bg-lime-500/10', + boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20', + hex: 'border-lime-500/40 bg-lime-500/20', + hexActive: 'border-lime-600 bg-lime-500/40', + }, + { + box: 'border-fuchsia-500/30 bg-fuchsia-500/10', + boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20', + hex: 'border-fuchsia-500/40 bg-fuchsia-500/20', + hexActive: 'border-fuchsia-600 bg-fuchsia-500/40', + }, +]; + +function formatTimestamp(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatSignal(packet: RawPacket): string { + const parts: string[] = []; + if (packet.rssi !== null) { + parts.push(`${packet.rssi} dBm RSSI`); + } + if (packet.snr !== null) { + parts.push(`${packet.snr.toFixed(1)} dB SNR`); + } + return parts.length > 0 ? parts.join(' · ') : 'No signal sample'; +} + +function formatByteRange(field: PacketByteField): string { + if (field.absoluteStartByte === field.absoluteEndByte) { + return `Byte ${field.absoluteStartByte}`; + } + return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`; +} + +function formatPathMode(hashSize: number | undefined, hopCount: number): string { + if (hopCount === 0) { + return 'No path hops'; + } + if (!hashSize) { + return `${hopCount} hop${hopCount === 1 ? '' : 's'}`; + } + return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`; +} + +function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] { + return channels.map((channel) => ({ + key: channel.key, + name: channel.name, + hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(), + })); +} + +function resolveGroupTextRoomName( + payload: { + channelHash?: string; + cipherMac?: string; + ciphertext?: string; + decrypted?: { message?: string }; + }, + candidates: GroupTextResolutionCandidate[] +): string | null { + if (!payload.channelHash) { + return null; + } + + const hashMatches = candidates.filter( + (candidate) => candidate.hash === payload.channelHash?.toUpperCase() + ); + if (hashMatches.length === 1) { + return hashMatches[0].name; + } + if ( + hashMatches.length <= 1 || + !payload.cipherMac || + !payload.ciphertext || + !payload.decrypted?.message + ) { + return null; + } + + const decryptMatches = hashMatches.filter( + (candidate) => + ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key) + .success + ); + return decryptMatches.length === 1 ? decryptMatches[0].name : null; +} + +function packetShowsDecryptedState( + packet: RawPacket, + inspection: ReturnType +): boolean { + const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined; + return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted); +} + +function getPacketContext( + packet: RawPacket, + inspection: ReturnType, + groupTextCandidates: GroupTextResolutionCandidate[] +) { + const fallbackSender = packet.decrypted_info?.sender ?? null; + const fallbackRoom = packet.decrypted_info?.channel_name ?? null; + + if (!inspection.decoded?.payload.decoded) { + if (!fallbackSender && !fallbackRoom) { + return null; + } + return { + title: fallbackRoom ? 'Room' : 'Context', + primary: fallbackRoom ?? 'Sender metadata available', + secondary: fallbackSender ? `Sender: ${fallbackSender}` : null, + }; + } + + if (inspection.decoded.payloadType === PayloadType.GroupText) { + const payload = inspection.decoded.payload.decoded as { + channelHash?: string; + cipherMac?: string; + ciphertext?: string; + decrypted?: { sender?: string; message?: string }; + }; + const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates); + return { + title: roomName ? 'Room' : 'Channel', + primary: + roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'), + secondary: payload.decrypted?.sender + ? `Sender: ${payload.decrypted.sender}` + : fallbackSender + ? `Sender: ${fallbackSender}` + : null, + }; + } + + if (fallbackSender) { + return { + title: 'Context', + primary: fallbackSender, + secondary: null, + }; + } + + return null; +} + +function buildDisplayFields(inspection: ReturnType) { + return [ + ...inspection.packetFields.filter((field) => field.name !== 'Payload'), + ...inspection.payloadFields, + ]; +} + +function buildFieldColorMap(fields: PacketByteField[]) { + return new Map( + fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]]) + ); +} + +function buildByteOwners(totalBytes: number, fields: PacketByteField[]) { + const owners = new Array(totalBytes).fill(null); + for (const field of fields) { + for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) { + if (index >= 0 && index < owners.length) { + owners[index] = field.id; + } + } + } + return owners; +} + +function CompactMetaCard({ + label, + primary, + secondary, +}: { + label: string; + primary: string; + secondary?: string | null; +}) { + return ( +
+
{label}
+
{primary}
+ {secondary ? ( +
{secondary}
+ ) : null} +
+ ); +} + +function FullPacketHex({ + packetHex, + fields, + colorMap, + hoveredFieldId, + onHoverField, +}: { + packetHex: string; + fields: PacketByteField[]; + colorMap: Map; + hoveredFieldId: string | null; + onHoverField: (fieldId: string | null) => void; +}) { + const normalized = packetHex.toUpperCase(); + const bytes = normalized.match(/.{1,2}/g) ?? []; + const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]); + + return ( +
+
+ {bytes.map((byte, byteIndex) => { + const fieldId = byteOwners[byteIndex]; + const palette = fieldId ? colorMap.get(fieldId) : null; + const active = fieldId !== null && hoveredFieldId === fieldId; + return ( + onHoverField(fieldId)} + onMouseLeave={() => onHoverField(null)} + className={cn( + 'rounded border px-1.5 py-1 leading-none transition-colors', + palette + ? active + ? palette.hexActive + : palette.hex + : 'border-border/70 bg-background/70 text-foreground' + )} + > + {byte} + + ); + })} +
+
+ ); +} + +function FieldBox({ + field, + palette, + active, + onHoverField, +}: { + field: PacketByteField; + palette: FieldPaletteEntry; + active: boolean; + onHoverField: (fieldId: string | null) => void; +}) { + return ( +
onHoverField(field.id)} + onMouseLeave={() => onHoverField(null)} + className={cn( + 'rounded-lg border p-2.5 transition-colors', + active ? palette.boxActive : palette.box + )} + > +
+
+
{field.name}
+
{formatByteRange(field)}
+
+
+ {field.value.toUpperCase()} +
+
+ +
+ {field.description} +
+ + {field.decryptedMessage ? ( +
+
+ {field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'} +
+ +
+ ) : null} + + {field.headerBreakdown ? ( +
+
+ {field.headerBreakdown.fullBinary} +
+ {field.headerBreakdown.fields.map((part) => ( +
+
+
+
+ {part.field} +
+
Bits {part.bits}
+
+
+
{part.binary}
+
{part.value}
+
+
+
+ ))} +
+ ) : null} +
+ ); +} + +function PlaintextContent({ text }: { text: string }) { + const lines = text.split('\n'); + + return ( +
+ {lines.map((line, index) => { + const separatorIndex = line.indexOf(': '); + if (separatorIndex === -1) { + return ( +
+ {line} +
+ ); + } + + const label = line.slice(0, separatorIndex + 1); + const value = line.slice(separatorIndex + 2); + + return ( +
+ {label} + {value} +
+ ); + })} +
+ ); +} + +function FieldSection({ + title, + fields, + colorMap, + hoveredFieldId, + onHoverField, +}: { + title: string; + fields: PacketByteField[]; + colorMap: Map; + hoveredFieldId: string | null; + onHoverField: (fieldId: string | null) => void; +}) { + return ( +
+
{title}
+ {fields.length === 0 ? ( +
No decoded fields available.
+ ) : ( +
+ {fields.map((field) => ( + + ))} +
+ )} +
+ ); +} + +export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) { + const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); + const groupTextCandidates = useMemo( + () => buildGroupTextResolutionCandidates(channels), + [channels] + ); + const inspection = useMemo( + () => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null), + [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] + ); + const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]); + const packetContext = useMemo( + () => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null), + [groupTextCandidates, inspection, packet] + ); + const packetIsDecrypted = useMemo( + () => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false), + [inspection, packet] + ); + + if (!packet || !inspection) { + return null; + } + + return ( + !isOpen && onClose()}> + + + Packet Details + + 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 9bbf7f6..e9dfcd6 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight } from 'lucide-react'; import { RawPacketList } from './RawPacketList'; -import type { Contact, RawPacket } from '../types'; +import { RawPacketDetailModal } from './RawPacketDetailModal'; +import type { Channel, Contact, RawPacket } from '../types'; import { RAW_PACKET_STATS_WINDOWS, buildRawPacketStatsSnapshot, @@ -19,6 +20,7 @@ interface RawPacketFeedViewProps { packets: RawPacket[]; rawPacketStatsSession: RawPacketStatsSessionState; contacts: Contact[]; + channels: Channel[]; } const WINDOW_LABELS: Record = { @@ -312,6 +314,7 @@ export function RawPacketFeedView({ packets, rawPacketStatsSession, contacts, + channels, }: RawPacketFeedViewProps) { const [statsOpen, setStatsOpen] = useState(() => typeof window !== 'undefined' && typeof window.matchMedia === 'function' @@ -320,6 +323,7 @@ export function RawPacketFeedView({ ); const [selectedWindow, setSelectedWindow] = useState('10m'); const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + const [selectedPacket, setSelectedPacket] = useState(null); useEffect(() => { const interval = window.setInterval(() => { @@ -376,7 +380,7 @@ export function RawPacketFeedView({
- +
+ + setSelectedPacket(null)} + /> ); } diff --git a/frontend/src/components/RawPacketList.tsx b/frontend/src/components/RawPacketList.tsx index ad26c16..1e1b3ae 100644 --- a/frontend/src/components/RawPacketList.tsx +++ b/frontend/src/components/RawPacketList.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useMemo } from 'react'; -import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder'; -import type { RawPacket } from '../types'; +import type { Channel, RawPacket } from '../types'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; +import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector'; import { cn } from '@/lib/utils'; interface RawPacketListProps { packets: RawPacket[]; + channels?: Channel[]; + onPacketClick?: (packet: RawPacket) => void; } function formatTime(timestamp: number): string { @@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string { return parts.join(' | '); } -// Decrypted info from the packet (validated by backend) -interface DecryptedInfo { - channel_name: string | null; - sender: string | null; -} - -// Decode a packet and generate a human-readable summary -// Uses backend's decrypted_info when available (validated), falls back to decoder -function decodePacketSummary( - hexData: string, - decryptedInfo: DecryptedInfo | null -): { - summary: string; - routeType: string; - details?: string; -} { - try { - const decoded = MeshCoreDecoder.decode(hexData); - - if (!decoded.isValid) { - return { summary: 'Invalid packet', routeType: 'Unknown' }; - } - - const routeType = Utils.getRouteTypeName(decoded.routeType); - const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType); - const tracePayload = - decoded.payloadType === PayloadType.Trace && decoded.payload.decoded - ? (decoded.payload.decoded as { pathHashes?: string[] }) - : null; - const pathTokens = tracePayload?.pathHashes || decoded.path || []; - - // Build path string if available - const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : ''; - - // Generate summary based on payload type - let summary = payloadTypeName; - let details: string | undefined; - - switch (decoded.payloadType) { - case PayloadType.TextMessage: { - const payload = decoded.payload.decoded as { - destinationHash?: string; - sourceHash?: string; - } | null; - if (payload?.sourceHash && payload?.destinationHash) { - summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`; - } else { - summary = `DM${pathStr}`; - } - break; - } - - case PayloadType.GroupText: { - const payload = decoded.payload.decoded as { - channelHash?: string; - } | null; - // Use backend's validated decrypted_info when available - if (decryptedInfo?.channel_name) { - if (decryptedInfo.sender) { - summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`; - } else { - summary = `GT in ${decryptedInfo.channel_name}${pathStr}`; - } - } else if (payload?.channelHash) { - // Fallback to showing channel hash when not decrypted - summary = `GT ch:${payload.channelHash}${pathStr}`; - } else { - summary = `GroupText${pathStr}`; - } - break; - } - - case PayloadType.Advert: { - const payload = decoded.payload.decoded as { - publicKey?: string; - appData?: { name?: string; deviceRole?: number }; - } | null; - if (payload?.appData?.name) { - const role = - payload.appData.deviceRole !== undefined - ? Utils.getDeviceRoleName(payload.appData.deviceRole) - : ''; - summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`; - } else if (payload?.publicKey) { - summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`; - } else { - summary = `Advert${pathStr}`; - } - break; - } - - case PayloadType.Ack: { - summary = `ACK${pathStr}`; - break; - } - - case PayloadType.Request: { - summary = `Request${pathStr}`; - break; - } - - case PayloadType.Response: { - summary = `Response${pathStr}`; - break; - } - - case PayloadType.Trace: { - summary = `Trace${pathStr}`; - break; - } - - case PayloadType.Path: { - summary = `Path${pathStr}`; - break; - } - - default: - summary = `${payloadTypeName}${pathStr}`; - } - - return { summary, routeType, details }; - } catch { - return { summary: 'Decode error', routeType: 'Unknown' }; - } -} - // Get route type badge color function getRouteTypeColor(routeType: string): string { switch (routeType) { @@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string { } } -export function RawPacketList({ packets }: RawPacketListProps) { +export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) { const listRef = useRef(null); + const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); // Decode all packets (memoized to avoid re-decoding on every render) const decodedPackets = useMemo(() => { return packets.map((packet) => ({ packet, - decoded: decodePacketSummary(packet.data, packet.decrypted_info), + decoded: decodePacketSummary(packet, decoderOptions), })); - }, [packets]); + }, [decoderOptions, packets]); // Sort packets by timestamp ascending (oldest first) const sortedPackets = useMemo( @@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) { className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]" ref={listRef} > - {sortedPackets.map(({ packet, decoded }) => ( -
-
- {/* Route type badge */} - - {getRouteTypeLabel(decoded.routeType)} - + {sortedPackets.map(({ packet, decoded }) => { + const cardContent = ( + <> +
+ {/* Route type badge */} + + {getRouteTypeLabel(decoded.routeType)} + - {/* Encryption status */} - {!packet.decrypted && ( - <> - - Encrypted - + {/* Encryption status */} + {!packet.decrypted && ( + <> + + Encrypted + + )} + + {/* Summary */} + + {decoded.summary} + + + {/* Time */} + + {formatTime(packet.timestamp)} + +
+ + {/* Signal info */} + {(packet.snr !== null || packet.rssi !== null) && ( +
+ {formatSignalInfo(packet)} +
)} - {/* Summary */} - - {decoded.summary} - - - {/* Time */} - - {formatTime(packet.timestamp)} - -
- - {/* Signal info */} - {(packet.snr !== null || packet.rssi !== null) && ( -
- {formatSignalInfo(packet)} + {/* Raw hex data (always visible) */} +
+ {packet.data.toUpperCase()}
- )} + + ); - {/* Raw hex data (always visible) */} -
- {packet.data.toUpperCase()} + const className = cn( + 'rounded-md border border-border/50 bg-card px-3 py-2 text-left', + onPacketClick && + 'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring' + ); + + if (onPacketClick) { + return ( + + ); + } + + return ( +
+ {cardContent}
-
- ))} + ); + })}
); } diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx index adccb87..fa02451 100644 --- a/frontend/src/test/rawPacketFeedView.test.tsx +++ b/frontend/src/test/rawPacketFeedView.test.tsx @@ -3,7 +3,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { RawPacketFeedView } from '../components/RawPacketFeedView'; import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; -import type { Contact, RawPacket } from '../types'; +import type { Channel, Contact, RawPacket } from '../types'; + +const GROUP_TEXT_PACKET_HEX = + '1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818'; + +const TEST_CHANNEL: Channel = { + key: '7ABA109EDCF304A84433CB71D0F3AB73', + name: '#six77', + is_hashtag: true, + on_radio: false, + last_read_at: null, +}; + +const COLLIDING_TEST_CHANNEL: Channel = { + ...TEST_CHANNEL, + name: '#collision', +}; function createSession( overrides: Partial = {} @@ -78,15 +94,34 @@ function createContact(overrides: Partial = {}): Contact { }; } +function renderView({ + packets = [], + contacts = [], + channels = [], + rawPacketStatsSession = createSession(), +}: { + packets?: RawPacket[]; + contacts?: Contact[]; + channels?: Channel[]; + rawPacketStatsSession?: RawPacketStatsSessionState; +} = {}) { + return render( + + ); +} + describe('RawPacketFeedView', () => { afterEach(() => { vi.unstubAllGlobals(); }); it('opens a stats drawer with window controls and grouped summaries', () => { - render( - - ); + renderView(); expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument(); expect(screen.queryByText('Packet Types')).not.toBeInTheDocument(); @@ -95,6 +130,7 @@ describe('RawPacketFeedView', () => { expect(screen.getByLabelText('Stats window')).toBeInTheDocument(); expect(screen.getByText('Packet Types')).toBeInTheDocument(); + expect(screen.getByText('Hop Byte Width')).toBeInTheDocument(); expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument(); expect(screen.getByText('Traffic Timeline')).toBeInTheDocument(); }); @@ -114,11 +150,10 @@ describe('RawPacketFeedView', () => { })) ); - render( - - ); + renderView(); expect(screen.getByText('Packet Types')).toBeInTheDocument(); + expect(screen.getByText('Hop Byte Width')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument(); }); @@ -161,13 +196,11 @@ describe('RawPacketFeedView', () => { ], }); - const { rerender } = render( - - ); + const { rerender } = renderView({ + packets: initialPackets, + rawPacketStatsSession: initialSession, + contacts: [], + }); fireEvent.click(screen.getByRole('button', { name: /show stats/i })); fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } }); @@ -179,6 +212,7 @@ describe('RawPacketFeedView', () => { packets={nextPackets} rawPacketStatsSession={initialSession} contacts={[]} + channels={[]} /> ); expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument(); @@ -195,7 +229,12 @@ describe('RawPacketFeedView', () => { ], }; rerender( - + ); expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument(); @@ -203,30 +242,27 @@ describe('RawPacketFeedView', () => { }); it('resolves neighbor labels from matching contacts when identity is available', () => { - render( - - ); + renderView({ + rawPacketStatsSession: createSession({ + totalObservedPackets: 1, + observations: [ + { + observationKey: 'obs-1', + timestamp: 1_700_000_000, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -70, + snr: 6, + sourceKey: 'AA11BB22CC33', + sourceLabel: 'AA11BB22CC33', + pathTokenCount: 1, + pathSignature: '01', + }, + ], + }), + contacts: [createContact()], + }); fireEvent.click(screen.getByRole('button', { name: /show stats/i })); fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } }); @@ -234,33 +270,86 @@ describe('RawPacketFeedView', () => { }); it('marks unresolved neighbor identities explicitly', () => { - render( - - ); + renderView({ + rawPacketStatsSession: createSession({ + totalObservedPackets: 1, + observations: [ + { + observationKey: 'obs-1', + timestamp: 1_700_000_000, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -70, + snr: 6, + sourceKey: 'DEADBEEF1234', + sourceLabel: 'DEADBEEF1234', + pathTokenCount: 1, + pathSignature: '01', + }, + ], + }), + contacts: [], + }); fireEvent.click(screen.getByRole('button', { name: /show stats/i })); fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } }); expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0); }); + + it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => { + renderView({ + packets: [ + { + id: 1, + observation_id: 10, + timestamp: 1_700_000_000, + data: GROUP_TEXT_PACKET_HEX, + decrypted: false, + payload_type: 'GroupText', + rssi: -72, + snr: 5.5, + decrypted_info: null, + }, + ], + channels: [TEST_CHANNEL], + }); + + fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i })); + + expect(screen.getByText('Packet Details')).toBeInTheDocument(); + expect(screen.getByText('Payload fields')).toBeInTheDocument(); + expect(screen.getByText('Full packet hex')).toBeInTheDocument(); + expect(screen.getByText('#six77')).toBeInTheDocument(); + expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument(); + expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0); + expect( + screen.getByText(/hello there; this hashtag room is essentially public/i) + ).toBeInTheDocument(); + }); + + it('does not guess a room name when multiple loaded channels collide on the group hash', () => { + renderView({ + packets: [ + { + id: 1, + observation_id: 10, + timestamp: 1_700_000_000, + data: GROUP_TEXT_PACKET_HEX, + decrypted: false, + payload_type: 'GroupText', + rssi: -72, + snr: 5.5, + decrypted_info: null, + }, + ], + channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL], + }); + + fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i })); + + expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument(); + expect(screen.queryByText('#six77')).not.toBeInTheDocument(); + expect(screen.queryByText('#collision')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/test/rawPacketInspector.test.ts b/frontend/src/test/rawPacketInspector.test.ts new file mode 100644 index 0000000..b6d2ef6 --- /dev/null +++ b/frontend/src/test/rawPacketInspector.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { PayloadType } from '@michaelhart/meshcore-decoder'; + +import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector'; + +describe('rawPacketInspector helpers', () => { + it('formats path hex as hop-delimited groups', () => { + expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6'); + expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC'); + }); + + it('leaves non-hop-aligned hex unchanged', () => { + expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3'); + expect(formatHexByHop('A1B2', null)).toBe('A1B2'); + }); + + it('describes undecryptable ciphertext with multiline bullets', () => { + expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain( + '\n• Timestamp (4 bytes)' + ); + expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain( + '\n• Flags (1 byte)' + ); + expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain( + '\n• Message (remaining bytes)' + ); + }); +}); diff --git a/frontend/src/test/rawPacketList.test.tsx b/frontend/src/test/rawPacketList.test.tsx index 4b558b8..41a17f9 100644 --- a/frontend/src/test/rawPacketList.test.tsx +++ b/frontend/src/test/rawPacketList.test.tsx @@ -1,5 +1,5 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import { RawPacketList } from '../components/RawPacketList'; import type { RawPacket } from '../types'; @@ -23,5 +23,17 @@ describe('RawPacketList', () => { render(); expect(screen.getByText('TF')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('makes packet cards clickable only when an inspector handler is provided', () => { + const packet = createPacket({ id: 9, observation_id: 22 }); + const onPacketClick = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole('button')); + + expect(onPacketClick).toHaveBeenCalledWith(packet); }); }); diff --git a/frontend/src/test/rawPacketStats.test.ts b/frontend/src/test/rawPacketStats.test.ts index 2839439..fa1e175 100644 --- a/frontend/src/test/rawPacketStats.test.ts +++ b/frontend/src/test/rawPacketStats.test.ts @@ -25,6 +25,7 @@ function createSession( sourceLabel: 'AA11', pathTokenCount: 2, pathSignature: '01>02', + hopByteWidth: 1, }, { observationKey: 'obs-2', @@ -38,6 +39,7 @@ function createSession( sourceLabel: 'BB22', pathTokenCount: 0, pathSignature: null, + hopByteWidth: null, }, { observationKey: 'obs-3', @@ -51,6 +53,7 @@ function createSession( sourceLabel: 'AA11', pathTokenCount: 1, pathSignature: '02', + hopByteWidth: 2, }, { observationKey: 'obs-4', @@ -64,6 +67,7 @@ function createSession( sourceLabel: null, pathTokenCount: 0, pathSignature: null, + hopByteWidth: null, }, ], ...overrides, @@ -88,6 +92,13 @@ describe('buildRawPacketStatsSnapshot', () => { expect.objectContaining({ label: 'Control', count: 0 }), ]) ); + expect(stats.hopByteWidthProfile).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'No path', count: 2 }), + expect.objectContaining({ label: '1 byte / hop', count: 1 }), + expect.objectContaining({ label: '2 bytes / hop', count: 1 }), + ]) + ); expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 }); expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 }); expect(stats.windowFullyCovered).toBe(true); diff --git a/frontend/src/utils/rawPacketInspector.ts b/frontend/src/utils/rawPacketInspector.ts new file mode 100644 index 0000000..e7ff3fb --- /dev/null +++ b/frontend/src/utils/rawPacketInspector.ts @@ -0,0 +1,388 @@ +import { + MeshCoreDecoder, + PayloadType, + Utils, + type DecodedPacket, + type DecryptionOptions, + type HeaderBreakdown, + type PacketStructure, +} from '@michaelhart/meshcore-decoder'; + +import type { Channel, RawPacket } from '../types'; + +export interface RawPacketSummary { + summary: string; + routeType: string; + details?: string; +} + +export interface PacketByteField { + id: string; + scope: 'packet' | 'payload'; + name: string; + description: string; + value: string; + decryptedMessage?: string; + startByte: number; + endByte: number; + absoluteStartByte: number; + absoluteEndByte: number; + headerBreakdown?: HeaderBreakdown; +} + +export interface RawPacketInspection { + decoded: DecodedPacket | null; + structure: PacketStructure | null; + routeTypeName: string; + payloadTypeName: string; + payloadVersionName: string; + pathTokens: string[]; + summary: RawPacketSummary; + validationErrors: string[]; + packetFields: PacketByteField[]; + payloadFields: PacketByteField[]; +} + +export function formatHexByHop(hex: string, hashSize: number | null | undefined): string { + const normalized = hex.trim().toUpperCase(); + if (!normalized || !hashSize || hashSize < 1) { + return normalized; + } + + const charsPerHop = hashSize * 2; + if (normalized.length <= charsPerHop || normalized.length % charsPerHop !== 0) { + return normalized; + } + + const hops = normalized.match(new RegExp(`.{1,${charsPerHop}}`, 'g')); + return hops && hops.length > 1 ? hops.join(' → ') : normalized; +} + +export function describeCiphertextStructure( + payloadType: PayloadType, + byteLength: number, + fallbackDescription: string +): string { + switch (payloadType) { + case PayloadType.GroupText: + return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure: +• Timestamp (4 bytes) - send time as unix timestamp +• Flags (1 byte) - room-message flags byte +• Message (remaining bytes) - UTF-8 room message text`; + case PayloadType.TextMessage: + return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure: +• Timestamp (4 bytes) - send time as unix timestamp +• Message (remaining bytes) - UTF-8 direct message text`; + case PayloadType.Response: + return `Encrypted response data (${byteLength} bytes). Contains encrypted plaintext with this structure: +• Tag (4 bytes) - request/response correlation tag +• Content (remaining bytes) - response body`; + default: + return fallbackDescription; + } +} + +function getPathTokens(decoded: DecodedPacket): string[] { + const tracePayload = + decoded.payloadType === PayloadType.Trace && decoded.payload.decoded + ? (decoded.payload.decoded as { pathHashes?: string[] }) + : null; + return tracePayload?.pathHashes || decoded.path || []; +} + +function formatUnixTimestamp(timestamp: number): string { + return `${timestamp} (${new Date(timestamp * 1000).toLocaleString()})`; +} + +function createPacketField( + scope: 'packet' | 'payload', + id: string, + field: { + name: string; + description: string; + value: string; + decryptedMessage?: string; + startByte: number; + endByte: number; + headerBreakdown?: HeaderBreakdown; + }, + absoluteOffset: number +): PacketByteField { + return { + id, + scope, + name: field.name, + description: field.description, + value: field.value, + decryptedMessage: field.decryptedMessage, + startByte: field.startByte, + endByte: field.endByte, + absoluteStartByte: absoluteOffset + field.startByte, + absoluteEndByte: absoluteOffset + field.endByte, + headerBreakdown: field.headerBreakdown, + }; +} + +export function createDecoderOptions( + channels: Channel[] | null | undefined +): DecryptionOptions | undefined { + const channelSecrets = + channels + ?.map((channel) => channel.key?.trim()) + .filter((key): key is string => Boolean(key && key.length > 0)) ?? []; + + if (channelSecrets.length === 0) { + return undefined; + } + + return { + keyStore: MeshCoreDecoder.createKeyStore({ channelSecrets }), + attemptDecryption: true, + }; +} + +function safeValidate(hexData: string): string[] { + try { + const validation = MeshCoreDecoder.validate(hexData); + return validation.errors ?? []; + } catch (error) { + return [error instanceof Error ? error.message : 'Packet validation failed']; + } +} + +export function decodePacketSummary( + packet: RawPacket, + decoderOptions?: DecryptionOptions +): RawPacketSummary { + try { + const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions); + + if (!decoded.isValid) { + return { summary: 'Invalid packet', routeType: 'Unknown' }; + } + + const routeType = Utils.getRouteTypeName(decoded.routeType); + const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType); + const pathTokens = getPathTokens(decoded); + const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join(', ')}` : ''; + + let summary = payloadTypeName; + let details: string | undefined; + + switch (decoded.payloadType) { + case PayloadType.TextMessage: { + const payload = decoded.payload.decoded as { + destinationHash?: string; + sourceHash?: string; + } | null; + if (payload?.sourceHash && payload?.destinationHash) { + summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`; + } else { + summary = `DM${pathStr}`; + } + break; + } + case PayloadType.GroupText: { + const payload = decoded.payload.decoded as { + channelHash?: string; + decrypted?: { sender?: string; message?: string }; + } | null; + if (packet.decrypted_info?.channel_name) { + if (packet.decrypted_info.sender) { + summary = `GT from ${packet.decrypted_info.sender} in ${packet.decrypted_info.channel_name}${pathStr}`; + } else { + summary = `GT in ${packet.decrypted_info.channel_name}${pathStr}`; + } + } else if (payload?.decrypted?.sender) { + summary = `GT from ${payload.decrypted.sender}${pathStr}`; + } else if (payload?.decrypted?.message) { + summary = `GT decrypted${pathStr}`; + } else if (payload?.channelHash) { + summary = `GT ch:${payload.channelHash}${pathStr}`; + } else { + summary = `GroupText${pathStr}`; + } + break; + } + case PayloadType.Advert: { + const payload = decoded.payload.decoded as { + publicKey?: string; + appData?: { name?: string; deviceRole?: number }; + } | null; + if (payload?.appData?.name) { + const role = + payload.appData.deviceRole !== undefined + ? Utils.getDeviceRoleName(payload.appData.deviceRole) + : ''; + summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`; + } else if (payload?.publicKey) { + summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`; + } else { + summary = `Advert${pathStr}`; + } + break; + } + case PayloadType.Ack: + summary = `ACK${pathStr}`; + break; + case PayloadType.Request: + summary = `Request${pathStr}`; + break; + case PayloadType.Response: + summary = `Response${pathStr}`; + break; + case PayloadType.Trace: + summary = `Trace${pathStr}`; + break; + case PayloadType.Path: + summary = `Path${pathStr}`; + break; + default: + summary = `${payloadTypeName}${pathStr}`; + break; + } + + return { summary, routeType, details }; + } catch { + return { summary: 'Decode error', routeType: 'Unknown' }; + } +} + +export function inspectRawPacket(packet: RawPacket): RawPacketInspection { + return inspectRawPacketWithOptions(packet); +} + +export function inspectRawPacketWithOptions( + packet: RawPacket, + decoderOptions?: DecryptionOptions +): RawPacketInspection { + const summary = decodePacketSummary(packet, decoderOptions); + const validationErrors = safeValidate(packet.data); + + let decoded: DecodedPacket | null = null; + let structure: PacketStructure | null = null; + + try { + decoded = MeshCoreDecoder.decode(packet.data, decoderOptions); + } catch { + decoded = null; + } + + try { + structure = MeshCoreDecoder.analyzeStructure(packet.data, decoderOptions); + } catch { + structure = null; + } + + const routeTypeName = decoded?.isValid + ? Utils.getRouteTypeName(decoded.routeType) + : summary.routeType; + const payloadTypeName = decoded?.isValid + ? Utils.getPayloadTypeName(decoded.payloadType) + : packet.payload_type; + const payloadVersionName = decoded?.isValid + ? Utils.getPayloadVersionName(decoded.payloadVersion) + : 'Unknown'; + const pathTokens = decoded?.isValid ? getPathTokens(decoded) : []; + + const packetFields = + structure?.segments + .map((segment, index) => createPacketField('packet', `packet-${index}`, segment, 0)) + .map((field) => { + if (field.name !== 'Path Data') { + return field; + } + const hashSize = + decoded?.pathHashSize ?? + (decoded?.pathLength && decoded.pathLength > 0 + ? Math.max(1, field.value.length / 2 / decoded.pathLength) + : null); + return { + ...field, + value: formatHexByHop(field.value, hashSize), + }; + }) ?? []; + + const payloadFields = + structure == null + ? [] + : (structure.payload.segments.length > 0 + ? structure.payload.segments + : structure.payload.hex.length > 0 + ? [ + { + name: 'Payload Bytes', + description: + 'Field-level payload breakdown is not available for this packet type.', + startByte: 0, + endByte: Math.max(0, structure.payload.hex.length / 2 - 1), + value: structure.payload.hex, + }, + ] + : [] + ).map((segment, index) => + createPacketField('payload', `payload-${index}`, segment, structure.payload.startByte) + ); + + const enrichedPayloadFields = + decoded?.isValid && decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded + ? payloadFields.map((field) => { + if (field.name !== 'Ciphertext') { + return field; + } + const payload = decoded.payload.decoded as { + decrypted?: { timestamp?: number; flags?: number; sender?: string; message?: string }; + }; + if (!payload.decrypted?.message) { + return field; + } + const detailLines = [ + payload.decrypted.timestamp != null + ? `Timestamp: ${formatUnixTimestamp(payload.decrypted.timestamp)}` + : null, + payload.decrypted.flags != null + ? `Flags: 0x${payload.decrypted.flags.toString(16).padStart(2, '0')}` + : null, + payload.decrypted.sender ? `Sender: ${payload.decrypted.sender}` : null, + `Message: ${payload.decrypted.message}`, + ].filter((line): line is string => line !== null); + return { + ...field, + description: describeCiphertextStructure( + decoded.payloadType, + field.endByte - field.startByte + 1, + field.description + ), + decryptedMessage: detailLines.join('\n'), + }; + }) + : payloadFields.map((field) => { + if (!decoded?.isValid || field.name !== 'Ciphertext') { + return field; + } + return { + ...field, + description: describeCiphertextStructure( + decoded.payloadType, + field.endByte - field.startByte + 1, + field.description + ), + }; + }); + + return { + decoded, + structure, + routeTypeName, + payloadTypeName, + payloadVersionName, + pathTokens, + summary, + validationErrors: + validationErrors.length > 0 + ? validationErrors + : (decoded?.errors ?? (decoded || structure ? [] : ['Unable to decode packet'])), + packetFields, + payloadFields: enrichedPayloadFields, + }; +} diff --git a/frontend/src/utils/rawPacketStats.ts b/frontend/src/utils/rawPacketStats.ts index af2b0b6..6784de6 100644 --- a/frontend/src/utils/rawPacketStats.ts +++ b/frontend/src/utils/rawPacketStats.ts @@ -51,6 +51,7 @@ export interface RawPacketStatsObservation { sourceLabel: string | null; pathTokenCount: number; pathSignature: string | null; + hopByteWidth?: number | null; } export interface RawPacketStatsSessionState { @@ -97,6 +98,7 @@ export interface RawPacketStatsSnapshot { routeBreakdown: RankedPacketStat[]; topPacketTypes: RankedPacketStat[]; hopProfile: RankedPacketStat[]; + hopByteWidthProfile: RankedPacketStat[]; strongestNeighbors: NeighborStat[]; mostActiveNeighbors: NeighborStat[]; newestNeighbors: NeighborStat[]; @@ -228,6 +230,7 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs sourceLabel: sourceInfo.sourceLabel, pathTokenCount: pathTokens.length, pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null, + hopByteWidth: pathTokens.length > 0 ? (decoded.pathHashSize ?? 1) : null, }; } catch { return { @@ -242,10 +245,26 @@ export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObs sourceLabel: null, pathTokenCount: 0, pathSignature: null, + hopByteWidth: null, }; } } +function inferHopByteWidth(packet: RawPacketStatsObservation): number | null { + if (packet.pathTokenCount <= 0) { + return null; + } + if (packet.hopByteWidth && packet.hopByteWidth > 0) { + return packet.hopByteWidth; + } + const firstToken = packet.pathSignature?.split('>')[0] ?? null; + if (!firstToken || firstToken.length % 2 !== 0) { + return null; + } + const inferred = firstToken.length / 2; + return inferred >= 1 && inferred <= 3 ? inferred : null; +} + function share(count: number, total: number): number { if (total <= 0) return 0; return count / total; @@ -306,6 +325,13 @@ export function buildRawPacketStatsSnapshot( ['1 hop', 0], ['2+ hops', 0], ]); + const hopByteWidthCounts = new Map([ + ['No path', 0], + ['1 byte / hop', 0], + ['2 bytes / hop', 0], + ['3 bytes / hop', 0], + ['Unknown width', 0], + ]); const neighborMap = new Map(); const rssiValues: number[] = []; const rssiBucketCounts = new Map([ @@ -328,6 +354,19 @@ export function buildRawPacketStatsSnapshot( hopCounts.set('2+ hops', (hopCounts.get('2+ hops') ?? 0) + 1); } + const hopByteWidth = inferHopByteWidth(packet); + if (packet.pathTokenCount <= 0) { + hopByteWidthCounts.set('No path', (hopByteWidthCounts.get('No path') ?? 0) + 1); + } else if (hopByteWidth === 1) { + hopByteWidthCounts.set('1 byte / hop', (hopByteWidthCounts.get('1 byte / hop') ?? 0) + 1); + } else if (hopByteWidth === 2) { + hopByteWidthCounts.set('2 bytes / hop', (hopByteWidthCounts.get('2 bytes / hop') ?? 0) + 1); + } else if (hopByteWidth === 3) { + hopByteWidthCounts.set('3 bytes / hop', (hopByteWidthCounts.get('3 bytes / hop') ?? 0) + 1); + } else { + hopByteWidthCounts.set('Unknown width', (hopByteWidthCounts.get('Unknown width') ?? 0) + 1); + } + if (packet.sourceKey && packet.sourceLabel) { const existing = neighborMap.get(packet.sourceKey); if (!existing) { @@ -448,6 +487,7 @@ export function buildRawPacketStatsSnapshot( routeBreakdown: rankedBreakdown(routeCounts, packetCount), topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5), hopProfile: rankedBreakdown(hopCounts, packetCount), + hopByteWidthProfile: rankedBreakdown(hopByteWidthCounts, packetCount), strongestNeighbors, mostActiveNeighbors, newestNeighbors,