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,
+ };
+}