From bd336e3ee2890e60b13a94924089be96e5cd71fd Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 18 Mar 2026 22:01:10 -0700 Subject: [PATCH] Add fancy metrics view for packet feed. Closes #75. --- frontend/AGENTS.md | 1 + frontend/src/App.tsx | 4 + frontend/src/components/ConversationPane.tsx | 18 +- frontend/src/components/RawPacketFeedView.tsx | 532 ++++++++++++++++++ frontend/src/hooks/index.ts | 1 + .../src/hooks/useRawPacketStatsSession.ts | 52 ++ frontend/src/hooks/useRealtimeAppState.ts | 4 + frontend/src/test/conversationPane.test.tsx | 9 + frontend/src/test/rawPacketFeedView.test.tsx | 266 +++++++++ frontend/src/test/rawPacketStats.test.ts | 108 ++++ frontend/src/utils/rawPacketStats.ts | 466 +++++++++++++++ 11 files changed, 1452 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/RawPacketFeedView.tsx create mode 100644 frontend/src/hooks/useRawPacketStatsSession.ts create mode 100644 frontend/src/test/rawPacketFeedView.test.tsx create mode 100644 frontend/src/test/rawPacketStats.test.ts create mode 100644 frontend/src/utils/rawPacketStats.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0f0d016..4a92efe 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -245,6 +245,7 @@ High-level state is delegated to hooks: - `id`: backend storage row identity (payload-level dedup) - `observation_id`: realtime per-arrival identity (session fidelity) - Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads). +- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session. ### Radio settings behavior diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 43425a4..197a169 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { useConversationNavigation, useRealtimeAppState, useBrowserNotifications, + useRawPacketStatsSession, } from './hooks'; import { AppShell } from './components/AppShell'; import type { MessageInputHandle } from './components/MessageInput'; @@ -81,6 +82,7 @@ export function App() { toggleConversationNotifications, notifyIncomingMessage, } = useBrowserNotifications(); + const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession(); const { showNewMessage, showSettings, @@ -331,6 +333,7 @@ export function App() { removeConversationMessages, receiveMessageAck, notifyIncomingMessage, + recordRawPacketObservation, }); const handleVisibilityPolicyChanged = useCallback(() => { clearConversationMessages(); @@ -413,6 +416,7 @@ export function App() { contacts, channels, rawPackets, + rawPacketStatsSession, config, health, favorites, diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 07b16f5..9f865e8 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -3,7 +3,7 @@ import { lazy, Suspense, useMemo, type Ref } from 'react'; import { ChatHeader } from './ChatHeader'; import { MessageInput, type MessageInputHandle } from './MessageInput'; import { MessageList } from './MessageList'; -import { RawPacketList } from './RawPacketList'; +import { RawPacketFeedView } from './RawPacketFeedView'; import type { Channel, Contact, @@ -15,6 +15,7 @@ import type { RawPacket, RadioConfig, } from '../types'; +import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; import { CONTACT_TYPE_REPEATER } from '../types'; import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey'; @@ -31,6 +32,7 @@ interface ConversationPaneProps { contacts: Contact[]; channels: Channel[]; rawPackets: RawPacket[]; + rawPacketStatsSession: RawPacketStatsSessionState; config: RadioConfig | null; health: HealthStatus | null; notificationsSupported: boolean; @@ -95,6 +97,7 @@ export function ConversationPane({ contacts, channels, rawPackets, + rawPacketStatsSession, config, health, notificationsSupported, @@ -178,14 +181,11 @@ export function ConversationPane({ if (activeConversation.type === 'raw') { return ( - <> -

- Raw Packet Feed -

-
- -
- + ); } diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx new file mode 100644 index 0000000..9bbf7f6 --- /dev/null +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -0,0 +1,532 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { RawPacketList } from './RawPacketList'; +import type { Contact, RawPacket } from '../types'; +import { + RAW_PACKET_STATS_WINDOWS, + buildRawPacketStatsSnapshot, + type NeighborStat, + type PacketTimelineBin, + type RankedPacketStat, + type RawPacketStatsSessionState, + type RawPacketStatsWindow, +} from '../utils/rawPacketStats'; +import { getContactDisplayName } from '../utils/pubkey'; +import { cn } from '@/lib/utils'; + +interface RawPacketFeedViewProps { + packets: RawPacket[]; + rawPacketStatsSession: RawPacketStatsSessionState; + contacts: Contact[]; +} + +const WINDOW_LABELS: Record = { + '1m': '1 min', + '5m': '5 min', + '10m': '10 min', + '30m': '30 min', + session: 'Session', +}; + +const TIMELINE_COLORS = [ + 'bg-sky-500/80', + 'bg-emerald-500/80', + 'bg-amber-500/80', + 'bg-rose-500/80', + 'bg-violet-500/80', +]; + +function formatTimestamp(timestampMs: number): string { + return new Date(timestampMs).toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) { + return `${Math.max(1, Math.round(seconds))} sec`; + } + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainder = Math.round(seconds % 60); + return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; + } + const hours = Math.floor(seconds / 3600); + const minutes = Math.round((seconds % 3600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; +} + +function formatRate(value: number): string { + if (value >= 100) return value.toFixed(0); + if (value >= 10) return value.toFixed(1); + return value.toFixed(2); +} + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function formatRssi(value: number | null): string { + return value === null ? '-' : `${Math.round(value)} dBm`; +} + +function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null { + if (!sourceKey || sourceKey.startsWith('name:')) { + return null; + } + + const normalizedSourceKey = sourceKey.toLowerCase(); + const matches = contacts.filter((contact) => + contact.public_key.toLowerCase().startsWith(normalizedSourceKey) + ); + if (matches.length !== 1) { + return null; + } + + const contact = matches[0]; + return getContactDisplayName(contact.name, contact.public_key, contact.last_advert); +} + +function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat { + return { + ...item, + label: resolveContactLabel(item.key, contacts) ?? item.label, + }; +} + +function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean { + if (item.key.startsWith('name:')) { + return true; + } + return resolveContactLabel(item.key, contacts) !== null; +} + +function formatStrongestPacketDetail( + stats: ReturnType, + contacts: Contact[] +): string | undefined { + if (!stats.strongestPacketPayloadType) { + return undefined; + } + + const resolvedLabel = + resolveContactLabel(stats.strongestPacketSourceKey, contacts) ?? + stats.strongestPacketSourceLabel; + if (resolvedLabel) { + return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`; + } + if (stats.strongestPacketPayloadType === 'GroupText') { + return ' · GroupText'; + } + return stats.strongestPacketPayloadType; +} + +function getCoverageMessage( + stats: ReturnType, + session: RawPacketStatsSessionState +): { tone: 'default' | 'warning'; message: string } { + if (session.trimmedObservationCount > 0 && stats.window === 'session') { + return { + tone: 'warning', + message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`, + }; + } + + if (!stats.windowFullyCovered) { + return { + tone: 'warning', + message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`, + }; + } + + return { + tone: 'default', + message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`, + }; +} + +function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) { + return ( +
+
{label}
+
{value}
+ {detail ?
{detail}
: null} +
+ ); +} + +function RankedBars({ + title, + items, + emptyLabel, + formatter, +}: { + title: string; + items: RankedPacketStat[]; + emptyLabel: string; + formatter?: (item: RankedPacketStat) => string; +}) { + const maxCount = Math.max(...items.map((item) => item.count), 1); + + return ( +
+

{title}

+ {items.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {items.map((item) => ( +
+
+ {item.label} + + {formatter + ? formatter(item) + : `${item.count.toLocaleString()} · ${formatPercent(item.share)}`} + +
+
+
+
+
+ ))} +
+ )} +
+ ); +} + +function NeighborList({ + title, + items, + emptyLabel, + mode, + contacts, +}: { + title: string; + items: NeighborStat[]; + emptyLabel: string; + mode: 'heard' | 'signal' | 'recent'; + contacts: Contact[]; +}) { + return ( +
+

{title}

+ {items.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {items.map((item) => ( +
+
+
{item.label}
+
+ {mode === 'heard' + ? `${item.count.toLocaleString()} packets` + : mode === 'signal' + ? `${formatRssi(item.bestRssi)} best` + : `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} +
+ {!isNeighborIdentityResolvable(item, contacts) ? ( +
Identity not resolvable
+ ) : null} +
+ {mode !== 'signal' ? ( +
+ {mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)} +
+ ) : null} +
+ ))} +
+ )} +
+ ); +} + +function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) { + const maxTotal = Math.max(...bins.map((bin) => bin.total), 1); + const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice( + 0, + TIMELINE_COLORS.length + ); + + return ( +
+
+

Traffic Timeline

+
+ {typeOrder.map((type, index) => ( + + + {type} + + ))} +
+
+ +
+ {bins.map((bin, index) => ( +
+
+
+ {typeOrder.map((type, index) => { + const count = bin.countsByType[type] ?? 0; + if (count === 0) return null; + return ( +
+ ); + })} +
+
+
{bin.label}
+
+ ))} +
+
+ ); +} + +export function RawPacketFeedView({ + packets, + rawPacketStatsSession, + contacts, +}: RawPacketFeedViewProps) { + const [statsOpen, setStatsOpen] = useState(() => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(min-width: 768px)').matches + : false + ); + const [selectedWindow, setSelectedWindow] = useState('10m'); + const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); + + useEffect(() => { + const interval = window.setInterval(() => { + setNowSec(Math.floor(Date.now() / 1000)); + }, 30000); + return () => window.clearInterval(interval); + }, []); + + useEffect(() => { + setNowSec(Math.floor(Date.now() / 1000)); + }, [packets, rawPacketStatsSession]); + + const stats = useMemo( + () => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec), + [nowSec, rawPacketStatsSession, selectedWindow] + ); + const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession); + const strongestPacketDetail = useMemo( + () => formatStrongestPacketDetail(stats, contacts), + [contacts, stats] + ); + const strongestNeighbors = useMemo( + () => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)), + [contacts, stats.strongestNeighbors] + ); + const mostActiveNeighbors = useMemo( + () => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)), + [contacts, stats.mostActiveNeighbors] + ); + const newestNeighbors = useMemo( + () => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)), + [contacts, stats.newestNeighbors] + ); + + return ( + <> +
+
+

Raw Packet Feed

+

+ Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)} +

+
+ +
+ +
+
+ +
+ + +
+ + ); +} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 184c9c2..828b573 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; export { useConversationNavigation } from './useConversationNavigation'; export { useBrowserNotifications } from './useBrowserNotifications'; +export { useRawPacketStatsSession } from './useRawPacketStatsSession'; diff --git a/frontend/src/hooks/useRawPacketStatsSession.ts b/frontend/src/hooks/useRawPacketStatsSession.ts new file mode 100644 index 0000000..24bfc6a --- /dev/null +++ b/frontend/src/hooks/useRawPacketStatsSession.ts @@ -0,0 +1,52 @@ +import { useCallback, useState } from 'react'; + +import type { RawPacket } from '../types'; +import { + MAX_RAW_PACKET_STATS_OBSERVATIONS, + summarizeRawPacketForStats, + type RawPacketStatsSessionState, +} from '../utils/rawPacketStats'; + +export function useRawPacketStatsSession() { + const [session, setSession] = useState(() => ({ + sessionStartedAt: Date.now(), + totalObservedPackets: 0, + trimmedObservationCount: 0, + observations: [], + })); + + const recordRawPacketObservation = useCallback((packet: RawPacket) => { + setSession((prev) => { + const observation = summarizeRawPacketForStats(packet); + if ( + prev.observations.some( + (candidate) => candidate.observationKey === observation.observationKey + ) + ) { + return prev; + } + + const observations = [...prev.observations, observation]; + if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) { + return { + ...prev, + totalObservedPackets: prev.totalObservedPackets + 1, + observations, + }; + } + + const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS; + return { + ...prev, + totalObservedPackets: prev.totalObservedPackets + 1, + trimmedObservationCount: prev.trimmedObservationCount + overflow, + observations: observations.slice(overflow), + }; + }); + }, []); + + return { + rawPacketStatsSession: session, + recordRawPacketObservation, + }; +} diff --git a/frontend/src/hooks/useRealtimeAppState.ts b/frontend/src/hooks/useRealtimeAppState.ts index 8c86c30..5b18b34 100644 --- a/frontend/src/hooks/useRealtimeAppState.ts +++ b/frontend/src/hooks/useRealtimeAppState.ts @@ -50,6 +50,7 @@ interface UseRealtimeAppStateArgs { removeConversationMessages: (conversationId: string) => void; receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void; notifyIncomingMessage?: (msg: Message) => void; + recordRawPacketObservation?: (packet: RawPacket) => void; maxRawPackets?: number; } @@ -97,6 +98,7 @@ export function useRealtimeAppState({ removeConversationMessages, receiveMessageAck, notifyIncomingMessage, + recordRawPacketObservation, maxRawPackets = 500, }: UseRealtimeAppStateArgs): UseWebSocketOptions { const mergeChannelIntoList = useCallback( @@ -241,6 +243,7 @@ export function useRealtimeAppState({ } }, onRawPacket: (packet: RawPacket) => { + recordRawPacketObservation?.(packet); setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets)); }, onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => { @@ -261,6 +264,7 @@ export function useRealtimeAppState({ pendingDeleteFallbackRef, prevHealthRef, recordMessageEvent, + recordRawPacketObservation, receiveMessageAck, observeMessage, refreshUnreads, diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 09222da..44d3782 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -12,6 +12,7 @@ import type { Message, RadioConfig, } from '../types'; +import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; const mocks = vi.hoisted(() => ({ messageList: vi.fn(() =>
), @@ -95,12 +96,20 @@ const message: Message = { sender_name: null, }; +const rawPacketStatsSession: RawPacketStatsSessionState = { + sessionStartedAt: 1_700_000_000_000, + totalObservedPackets: 0, + trimmedObservationCount: 0, + observations: [], +}; + function createProps(overrides: Partial> = {}) { return { activeConversation: null as Conversation | null, contacts: [] as Contact[], channels: [channel], rawPackets: [], + rawPacketStatsSession, config, health, notificationsSupported: true, diff --git a/frontend/src/test/rawPacketFeedView.test.tsx b/frontend/src/test/rawPacketFeedView.test.tsx new file mode 100644 index 0000000..adccb87 --- /dev/null +++ b/frontend/src/test/rawPacketFeedView.test.tsx @@ -0,0 +1,266 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +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'; + +function createSession( + overrides: Partial = {} +): RawPacketStatsSessionState { + return { + sessionStartedAt: 1_700_000_000_000, + totalObservedPackets: 3, + trimmedObservationCount: 0, + observations: [ + { + observationKey: 'obs-1', + timestamp: 1_700_000_000, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -70, + snr: 6, + sourceKey: 'AA11', + sourceLabel: 'AA11', + pathTokenCount: 1, + pathSignature: '01', + }, + { + observationKey: 'obs-2', + timestamp: 1_700_000_030, + payloadType: 'TextMessage', + routeType: 'Direct', + decrypted: true, + rssi: -66, + snr: 7, + sourceKey: 'BB22', + sourceLabel: 'BB22', + pathTokenCount: 0, + pathSignature: null, + }, + { + observationKey: 'obs-3', + timestamp: 1_700_000_050, + payloadType: 'Ack', + routeType: 'Direct', + decrypted: true, + rssi: -80, + snr: 4, + sourceKey: 'BB22', + sourceLabel: 'BB22', + pathTokenCount: 0, + pathSignature: null, + }, + ], + ...overrides, + }; +} + +function createContact(overrides: Partial = {}): Contact { + return { + public_key: 'aa11bb22cc33' + '0'.repeat(52), + name: 'Alpha', + type: 1, + flags: 0, + direct_path: null, + direct_path_len: 0, + direct_path_hash_mode: 0, + last_advert: 1_700_000_000, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + ...overrides, + }; +} + +describe('RawPacketFeedView', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('opens a stats drawer with window controls and grouped summaries', () => { + render( + + ); + + expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument(); + expect(screen.queryByText('Packet Types')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /show stats/i })); + + expect(screen.getByLabelText('Stats window')).toBeInTheDocument(); + expect(screen.getByText('Packet Types')).toBeInTheDocument(); + expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument(); + expect(screen.getByText('Traffic Timeline')).toBeInTheDocument(); + }); + + it('shows stats by default on desktop', () => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches: query === '(min-width: 768px)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) + ); + + render( + + ); + + expect(screen.getByText('Packet Types')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument(); + }); + + it('refreshes coverage when packet or session props update without counter deltas', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-01T00:00:30Z')); + + const initialPackets: RawPacket[] = []; + const nextPackets: RawPacket[] = [ + { + id: 1, + timestamp: 1_704_067_255, + data: '00', + decrypted: false, + payload_type: 'Unknown', + rssi: null, + snr: null, + observation_id: 1, + decrypted_info: null, + }, + ]; + const initialSession = createSession({ + sessionStartedAt: Date.parse('2024-01-01T00:00:00Z'), + totalObservedPackets: 10, + trimmedObservationCount: 1, + observations: [ + { + observationKey: 'obs-1', + timestamp: 1_704_067_220, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -70, + snr: 6, + sourceKey: 'AA11', + sourceLabel: 'AA11', + pathTokenCount: 1, + pathSignature: '01', + }, + ], + }); + + const { rerender } = render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /show stats/i })); + fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } }); + expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument(); + + vi.setSystemTime(new Date('2024-01-01T00:01:10Z')); + rerender( + + ); + expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument(); + + vi.setSystemTime(new Date('2024-01-01T00:01:30Z')); + const nextSession = { + ...initialSession, + sessionStartedAt: Date.parse('2024-01-01T00:01:00Z'), + observations: [ + { + ...initialSession.observations[0], + timestamp: 1_704_067_280, + }, + ], + }; + rerender( + + ); + expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument(); + + vi.useRealTimers(); + }); + + it('resolves neighbor labels from matching contacts when identity is available', () => { + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /show stats/i })); + fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } }); + expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0); + }); + + it('marks unresolved neighbor identities explicitly', () => { + render( + + ); + + 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); + }); +}); diff --git a/frontend/src/test/rawPacketStats.test.ts b/frontend/src/test/rawPacketStats.test.ts new file mode 100644 index 0000000..2839439 --- /dev/null +++ b/frontend/src/test/rawPacketStats.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildRawPacketStatsSnapshot, + type RawPacketStatsSessionState, +} from '../utils/rawPacketStats'; + +function createSession( + overrides: Partial = {} +): RawPacketStatsSessionState { + return { + sessionStartedAt: 700_000, + totalObservedPackets: 4, + trimmedObservationCount: 0, + observations: [ + { + observationKey: 'obs-1', + timestamp: 850, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -68, + snr: 7, + sourceKey: 'AA11', + sourceLabel: 'AA11', + pathTokenCount: 2, + pathSignature: '01>02', + }, + { + observationKey: 'obs-2', + timestamp: 910, + payloadType: 'TextMessage', + routeType: 'Direct', + decrypted: true, + rssi: -74, + snr: 5, + sourceKey: 'BB22', + sourceLabel: 'BB22', + pathTokenCount: 0, + pathSignature: null, + }, + { + observationKey: 'obs-3', + timestamp: 960, + payloadType: 'Advert', + routeType: 'Flood', + decrypted: false, + rssi: -64, + snr: 8, + sourceKey: 'AA11', + sourceLabel: 'AA11', + pathTokenCount: 1, + pathSignature: '02', + }, + { + observationKey: 'obs-4', + timestamp: 990, + payloadType: 'Ack', + routeType: 'Direct', + decrypted: true, + rssi: -88, + snr: 3, + sourceKey: null, + sourceLabel: null, + pathTokenCount: 0, + pathSignature: null, + }, + ], + ...overrides, + }; +} + +describe('buildRawPacketStatsSnapshot', () => { + it('computes counts, rankings, and rolling-window coverage from session observations', () => { + const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000); + + expect(stats.packetCount).toBe(4); + expect(stats.uniqueSources).toBe(2); + expect(stats.pathBearingCount).toBe(2); + expect(stats.payloadBreakdown.slice(0, 3).map((item) => item.label)).toEqual([ + 'Advert', + 'Ack', + 'TextMessage', + ]); + expect(stats.payloadBreakdown).toEqual( + expect.arrayContaining([ + expect.objectContaining({ label: 'GroupText', count: 0 }), + expect.objectContaining({ label: 'Control', count: 0 }), + ]) + ); + expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 }); + expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 }); + expect(stats.windowFullyCovered).toBe(true); + }); + + it('flags incomplete session coverage when detailed history has been trimmed', () => { + const stats = buildRawPacketStatsSnapshot( + createSession({ + trimmedObservationCount: 25, + }), + 'session', + 1_000 + ); + + expect(stats.windowFullyCovered).toBe(false); + expect(stats.packetCount).toBe(4); + }); +}); diff --git a/frontend/src/utils/rawPacketStats.ts b/frontend/src/utils/rawPacketStats.ts new file mode 100644 index 0000000..af2b0b6 --- /dev/null +++ b/frontend/src/utils/rawPacketStats.ts @@ -0,0 +1,466 @@ +import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder'; + +import type { RawPacket } from '../types'; +import { getRawPacketObservationKey } from './rawPacketIdentity'; + +export const RAW_PACKET_STATS_WINDOWS = ['1m', '5m', '10m', '30m', 'session'] as const; +export type RawPacketStatsWindow = (typeof RAW_PACKET_STATS_WINDOWS)[number]; + +export const RAW_PACKET_STATS_WINDOW_SECONDS: Record< + Exclude, + number +> = { + '1m': 60, + '5m': 5 * 60, + '10m': 10 * 60, + '30m': 30 * 60, +}; + +export const MAX_RAW_PACKET_STATS_OBSERVATIONS = 20000; + +const KNOWN_PAYLOAD_TYPES = [ + 'Advert', + 'GroupText', + 'TextMessage', + 'Ack', + 'Request', + 'Response', + 'Trace', + 'Path', + 'Control', + 'Unknown', +] as const; + +const KNOWN_ROUTE_TYPES = [ + 'Flood', + 'Direct', + 'TransportFlood', + 'TransportDirect', + 'Unknown', +] as const; + +export interface RawPacketStatsObservation { + observationKey: string; + timestamp: number; + payloadType: string; + routeType: string; + decrypted: boolean; + rssi: number | null; + snr: number | null; + sourceKey: string | null; + sourceLabel: string | null; + pathTokenCount: number; + pathSignature: string | null; +} + +export interface RawPacketStatsSessionState { + sessionStartedAt: number; + totalObservedPackets: number; + trimmedObservationCount: number; + observations: RawPacketStatsObservation[]; +} + +export interface RankedPacketStat { + label: string; + count: number; + share: number; +} + +export interface NeighborStat { + key: string; + label: string; + count: number; + bestRssi: number | null; + lastSeen: number; +} + +export interface PacketTimelineBin { + label: string; + total: number; + countsByType: Record; +} + +export interface RawPacketStatsSnapshot { + window: RawPacketStatsWindow; + nowSec: number; + packets: RawPacketStatsObservation[]; + packetCount: number; + packetsPerMinute: number; + uniqueSources: number; + decryptedCount: number; + undecryptedCount: number; + decryptRate: number; + pathBearingCount: number; + pathBearingRate: number; + distinctPaths: number; + payloadBreakdown: RankedPacketStat[]; + routeBreakdown: RankedPacketStat[]; + topPacketTypes: RankedPacketStat[]; + hopProfile: RankedPacketStat[]; + strongestNeighbors: NeighborStat[]; + mostActiveNeighbors: NeighborStat[]; + newestNeighbors: NeighborStat[]; + averageRssi: number | null; + medianRssi: number | null; + bestRssi: number | null; + rssiBuckets: RankedPacketStat[]; + strongestPacketSourceKey: string | null; + strongestPacketSourceLabel: string | null; + strongestPacketPayloadType: string | null; + coverageSeconds: number; + windowFullyCovered: boolean; + oldestStoredTimestamp: number | null; + timeline: PacketTimelineBin[]; +} + +function toSourceLabel(sourceKey: string): string { + if (sourceKey.startsWith('name:')) { + return sourceKey.slice(5); + } + return sourceKey.slice(0, 12).toUpperCase(); +} + +function getPathTokens(decoded: ReturnType): string[] { + const tracePayload = + decoded.payloadType === PayloadType.Trace && decoded.payload.decoded + ? (decoded.payload.decoded as { pathHashes?: string[] }) + : null; + return tracePayload?.pathHashes || decoded.path || []; +} + +function getSourceInfo( + packet: RawPacket, + decoded: ReturnType +): Pick { + if (!decoded.isValid || !decoded.payload.decoded) { + const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null; + if (fallbackContactKey) { + return { + sourceKey: fallbackContactKey, + sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey), + }; + } + if (packet.decrypted_info?.sender) { + return { + sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`, + sourceLabel: packet.decrypted_info.sender, + }; + } + return { sourceKey: null, sourceLabel: null }; + } + + switch (decoded.payloadType) { + case PayloadType.Advert: { + const publicKey = (decoded.payload.decoded as { publicKey?: string }).publicKey; + if (!publicKey) return { sourceKey: null, sourceLabel: null }; + return { + sourceKey: publicKey.toUpperCase(), + sourceLabel: publicKey.slice(0, 12).toUpperCase(), + }; + } + case PayloadType.TextMessage: + case PayloadType.Request: + case PayloadType.Response: { + const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash; + if (!sourceHash) return { sourceKey: null, sourceLabel: null }; + return { + sourceKey: sourceHash.toUpperCase(), + sourceLabel: sourceHash.toUpperCase(), + }; + } + case PayloadType.GroupText: { + const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null; + if (contactKey) { + return { + sourceKey: contactKey, + sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey), + }; + } + if (packet.decrypted_info?.sender) { + return { + sourceKey: `name:${packet.decrypted_info.sender.toLowerCase()}`, + sourceLabel: packet.decrypted_info.sender, + }; + } + return { sourceKey: null, sourceLabel: null }; + } + case PayloadType.AnonRequest: { + const senderPublicKey = (decoded.payload.decoded as { senderPublicKey?: string }) + .senderPublicKey; + if (!senderPublicKey) return { sourceKey: null, sourceLabel: null }; + return { + sourceKey: senderPublicKey.toUpperCase(), + sourceLabel: senderPublicKey.slice(0, 12).toUpperCase(), + }; + } + default: { + const fallbackContactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null; + if (fallbackContactKey) { + return { + sourceKey: fallbackContactKey, + sourceLabel: packet.decrypted_info?.sender || toSourceLabel(fallbackContactKey), + }; + } + return { sourceKey: null, sourceLabel: null }; + } + } +} + +export function summarizeRawPacketForStats(packet: RawPacket): RawPacketStatsObservation { + try { + const decoded = MeshCoreDecoder.decode(packet.data); + const pathTokens = decoded.isValid ? getPathTokens(decoded) : []; + const payloadType = decoded.isValid + ? Utils.getPayloadTypeName(decoded.payloadType) + : packet.payload_type; + const routeType = decoded.isValid ? Utils.getRouteTypeName(decoded.routeType) : 'Unknown'; + const sourceInfo = getSourceInfo(packet, decoded); + + return { + observationKey: getRawPacketObservationKey(packet), + timestamp: packet.timestamp, + payloadType, + routeType, + decrypted: packet.decrypted, + rssi: packet.rssi, + snr: packet.snr, + sourceKey: sourceInfo.sourceKey, + sourceLabel: sourceInfo.sourceLabel, + pathTokenCount: pathTokens.length, + pathSignature: pathTokens.length > 0 ? pathTokens.join('>') : null, + }; + } catch { + return { + observationKey: getRawPacketObservationKey(packet), + timestamp: packet.timestamp, + payloadType: packet.payload_type, + routeType: 'Unknown', + decrypted: packet.decrypted, + rssi: packet.rssi, + snr: packet.snr, + sourceKey: null, + sourceLabel: null, + pathTokenCount: 0, + pathSignature: null, + }; + } +} + +function share(count: number, total: number): number { + if (total <= 0) return 0; + return count / total; +} + +function createCountsMap(labels: readonly string[]): Map { + return new Map(labels.map((label) => [label, 0])); +} + +function rankedBreakdown(counts: Map, total: number): RankedPacketStat[] { + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([label, count]) => ({ label, count, share: share(count, total) })); +} + +function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 1) { + return sorted[mid]; + } + return (sorted[mid - 1] + sorted[mid]) / 2; +} + +function formatTimelineLabel(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} + +export function buildRawPacketStatsSnapshot( + session: RawPacketStatsSessionState, + window: RawPacketStatsWindow, + nowSec: number = Math.floor(Date.now() / 1000) +): RawPacketStatsSnapshot { + const sessionStartedSec = Math.floor(session.sessionStartedAt / 1000); + const windowSeconds = window === 'session' ? null : RAW_PACKET_STATS_WINDOW_SECONDS[window]; + const windowStart = windowSeconds === null ? sessionStartedSec : nowSec - windowSeconds; + const packets = session.observations.filter((packet) => packet.timestamp >= windowStart); + const packetCount = packets.length; + const uniqueSources = new Set(packets.map((packet) => packet.sourceKey).filter(Boolean)).size; + const decryptedCount = packets.filter((packet) => packet.decrypted).length; + const undecryptedCount = packetCount - decryptedCount; + const pathBearingCount = packets.filter((packet) => packet.pathTokenCount > 0).length; + const distinctPaths = new Set( + packets.map((packet) => packet.pathSignature).filter((value): value is string => Boolean(value)) + ).size; + const effectiveCoverageSeconds = + windowSeconds ?? Math.max(1, nowSec - Math.min(sessionStartedSec, nowSec)); + const packetsPerMinute = packetCount / Math.max(effectiveCoverageSeconds / 60, 1 / 60); + + const payloadCounts = createCountsMap(KNOWN_PAYLOAD_TYPES); + const routeCounts = createCountsMap(KNOWN_ROUTE_TYPES); + const hopCounts = new Map([ + ['Direct', 0], + ['1 hop', 0], + ['2+ hops', 0], + ]); + const neighborMap = new Map(); + const rssiValues: number[] = []; + const rssiBucketCounts = new Map([ + ['Strong (>-70 dBm)', 0], + ['Okay (-70 to -85 dBm)', 0], + ['Weak (<-85 dBm)', 0], + ]); + + let strongestPacket: RawPacketStatsObservation | null = null; + + for (const packet of packets) { + payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1); + routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1); + + if (packet.pathTokenCount <= 0) { + hopCounts.set('Direct', (hopCounts.get('Direct') ?? 0) + 1); + } else if (packet.pathTokenCount === 1) { + hopCounts.set('1 hop', (hopCounts.get('1 hop') ?? 0) + 1); + } else { + hopCounts.set('2+ hops', (hopCounts.get('2+ hops') ?? 0) + 1); + } + + if (packet.sourceKey && packet.sourceLabel) { + const existing = neighborMap.get(packet.sourceKey); + if (!existing) { + neighborMap.set(packet.sourceKey, { + key: packet.sourceKey, + label: packet.sourceLabel, + count: 1, + bestRssi: packet.rssi, + lastSeen: packet.timestamp, + }); + } else { + existing.count += 1; + existing.lastSeen = Math.max(existing.lastSeen, packet.timestamp); + if ( + packet.rssi !== null && + (existing.bestRssi === null || packet.rssi > existing.bestRssi) + ) { + existing.bestRssi = packet.rssi; + } + } + } + + if (packet.rssi !== null) { + rssiValues.push(packet.rssi); + if (packet.rssi > -70) { + rssiBucketCounts.set( + 'Strong (>-70 dBm)', + (rssiBucketCounts.get('Strong (>-70 dBm)') ?? 0) + 1 + ); + } else if (packet.rssi >= -85) { + rssiBucketCounts.set( + 'Okay (-70 to -85 dBm)', + (rssiBucketCounts.get('Okay (-70 to -85 dBm)') ?? 0) + 1 + ); + } else { + rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1); + } + + if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) { + strongestPacket = packet; + } + } + } + + const averageRssi = + rssiValues.length > 0 + ? rssiValues.reduce((sum, value) => sum + value, 0) / rssiValues.length + : null; + const bestRssi = rssiValues.length > 0 ? Math.max(...rssiValues) : null; + const medianRssi = median(rssiValues); + const neighbors = Array.from(neighborMap.values()); + const strongestNeighbors = [...neighbors] + .filter((neighbor) => neighbor.bestRssi !== null) + .sort( + (a, b) => + (b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) || + b.count - a.count || + a.label.localeCompare(b.label) + ) + .slice(0, 5); + const mostActiveNeighbors = [...neighbors] + .sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label)) + .slice(0, 5); + const newestNeighbors = [...neighbors] + .sort((a, b) => b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label)) + .slice(0, 5); + + const oldestStoredTimestamp = session.observations[0]?.timestamp ?? null; + const detailedCoverageStart = + session.trimmedObservationCount > 0 ? (oldestStoredTimestamp ?? nowSec) : sessionStartedSec; + const windowFullyCovered = + window === 'session' + ? session.trimmedObservationCount === 0 + : detailedCoverageStart <= windowStart; + const coverageStart = Math.max(windowStart, detailedCoverageStart); + const coverageSeconds = + window === 'session' + ? Math.max(1, nowSec - detailedCoverageStart) + : Math.max(1, nowSec - coverageStart); + + const timelineSpanSeconds = Math.max( + windowSeconds ?? Math.max(60, nowSec - sessionStartedSec), + 60 + ); + const timelineBinCount = 10; + const binWidth = Math.max(1, timelineSpanSeconds / timelineBinCount); + const timeline = Array.from({ length: timelineBinCount }, (_, index) => { + const start = Math.floor(windowStart + index * binWidth); + return { + label: formatTimelineLabel(start), + total: 0, + countsByType: {} as Record, + }; + }); + + for (const packet of packets) { + const rawIndex = Math.floor((packet.timestamp - windowStart) / binWidth); + const index = Math.max(0, Math.min(timelineBinCount - 1, rawIndex)); + const bin = timeline[index]; + bin.total += 1; + bin.countsByType[packet.payloadType] = (bin.countsByType[packet.payloadType] ?? 0) + 1; + } + + return { + window, + nowSec, + packets, + packetCount, + packetsPerMinute, + uniqueSources, + decryptedCount, + undecryptedCount, + decryptRate: share(decryptedCount, packetCount), + pathBearingCount, + pathBearingRate: share(pathBearingCount, packetCount), + distinctPaths, + payloadBreakdown: rankedBreakdown(payloadCounts, packetCount), + routeBreakdown: rankedBreakdown(routeCounts, packetCount), + topPacketTypes: rankedBreakdown(payloadCounts, packetCount).slice(0, 5), + hopProfile: rankedBreakdown(hopCounts, packetCount), + strongestNeighbors, + mostActiveNeighbors, + newestNeighbors, + averageRssi, + medianRssi, + bestRssi, + rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length), + strongestPacketSourceKey: strongestPacket?.sourceKey ?? null, + strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null, + strongestPacketPayloadType: strongestPacket?.payloadType ?? null, + coverageSeconds, + windowFullyCovered, + oldestStoredTimestamp, + timeline, + }; +}