Add fancy metrics view for packet feed. Closes #75.

This commit is contained in:
Jack Kingsman
2026-03-18 22:01:10 -07:00
parent cf585cdf87
commit bd336e3ee2
11 changed files with 1452 additions and 9 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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 (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
<RawPacketFeedView
packets={rawPackets}
rawPacketStatsSession={rawPacketStatsSession}
contacts={contacts}
/>
);
}

View File

@@ -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<RawPacketStatsWindow, string> = {
'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<typeof buildRawPacketStatsSnapshot>,
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 '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
}
function getCoverageMessage(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
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 (
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
</div>
);
}
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 (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</section>
);
}
function NeighborList({
title,
items,
emptyLabel,
mode,
contacts,
}: {
title: string;
items: NeighborStat[];
emptyLabel: string;
mode: 'heard' | 'signal' | 'recent';
contacts: Contact[];
}) {
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div
key={item.key}
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
>
<div className="min-w-0">
<div className="truncate text-sm text-foreground">{item.label}</div>
<div className="text-xs text-muted-foreground">
{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' })}`}
</div>
{!isNeighborIdentityResolvable(item, contacts) ? (
<div className="text-[11px] text-warning">Identity not resolvable</div>
) : null}
</div>
{mode !== 'signal' ? (
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
</div>
) : null}
</div>
))}
</div>
)}
</section>
);
}
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 (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, index) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
</div>
))}
</div>
</section>
);
}
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<RawPacketStatsWindow>('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 (
<>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<p className="text-xs text-muted-foreground">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
</p>
</div>
<button
type="button"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList packets={packets} />
</div>
<aside
className={cn(
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
statsOpen
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
)}
>
{statsOpen ? (
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Coverage
</div>
<div
className={cn(
'mt-1 text-sm',
coverageMessage.tone === 'warning'
? 'text-warning'
: 'text-muted-foreground'
)}
>
{coverageMessage.message}
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<span className="text-muted-foreground">Window</span>
<select
value={selectedWindow}
onChange={(event) =>
setSelectedWindow(event.target.value as RawPacketStatsWindow)
}
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
aria-label="Stats window"
>
{RAW_PACKET_STATS_WINDOWS.map((option) => (
<option key={option} value={option}>
{WINDOW_LABELS[option]}
</option>
))}
</select>
</label>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{stats.packetCount.toLocaleString()} packets in{' '}
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
{' · '}
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
session
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
<StatTile
label="Packets / min"
value={formatRate(stats.packetsPerMinute)}
detail={`${stats.packetCount.toLocaleString()} total in window`}
/>
<StatTile
label="Unique Sources"
value={stats.uniqueSources.toLocaleString()}
detail="Distinct identified senders"
/>
<StatTile
label="Decrypt Rate"
value={formatPercent(stats.decryptRate)}
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
/>
<StatTile
label="Path Diversity"
value={stats.distinctPaths.toLocaleString()}
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
/>
<StatTile
label="Median RSSI"
value={formatRssi(stats.medianRssi)}
detail={
stats.averageRssi === null
? 'No signal sample in window'
: `Average ${formatRssi(stats.averageRssi)}`
}
/>
</div>
<div className="mt-4">
<TimelineChart bins={stats.timeline} />
</div>
<div className="md:columns-2 md:gap-4">
<RankedBars
title="Packet Types"
items={stats.payloadBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Route Mix"
items={stats.routeBreakdown}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Hop Profile"
items={stats.hopProfile}
emptyLabel="No packets in this window yet."
/>
<RankedBars
title="Signal Distribution"
items={stats.rssiBuckets}
emptyLabel="No RSSI samples in this window yet."
/>
<NeighborList
title="Most-Heard Neighbors"
items={mostActiveNeighbors}
emptyLabel="No sender identities resolved in this window yet."
mode="heard"
contacts={contacts}
/>
<NeighborList
title="Strongest Recent Neighbors"
items={strongestNeighbors}
emptyLabel="No RSSI-tagged neighbors in this window yet."
mode="signal"
contacts={contacts}
/>
<NeighborList
title="Newest Heard Neighbors"
items={newestNeighbors}
emptyLabel="No newly identified neighbors in this window yet."
mode="recent"
contacts={contacts}
/>
</div>
</div>
) : null}
</aside>
</div>
</>
);
}

View File

@@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';
export { useRawPacketStatsSession } from './useRawPacketStatsSession';

View File

@@ -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<RawPacketStatsSessionState>(() => ({
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,
};
}

View File

@@ -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,

View File

@@ -12,6 +12,7 @@ import type {
Message,
RadioConfig,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
@@ -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<React.ComponentProps<typeof ConversationPane>> = {}) {
return {
activeConversation: null as Conversation | null,
contacts: [] as Contact[],
channels: [channel],
rawPackets: [],
rawPacketStatsSession,
config,
health,
notificationsSupported: true,

View File

@@ -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> = {}
): 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> = {}): 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(
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
);
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(
<RawPacketFeedView packets={[]} rawPacketStatsSession={createSession()} contacts={[]} />
);
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(
<RawPacketFeedView
packets={initialPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
/>
);
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(
<RawPacketFeedView
packets={nextPackets}
rawPacketStatsSession={initialSession}
contacts={[]}
/>
);
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(
<RawPacketFeedView packets={nextPackets} rawPacketStatsSession={nextSession} contacts={[]} />
);
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
vi.useRealTimers();
});
it('resolves neighbor labels from matching contacts when identity is available', () => {
render(
<RawPacketFeedView
packets={[]}
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' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
});
it('marks unresolved neighbor identities explicitly', () => {
render(
<RawPacketFeedView
packets={[]}
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);
});
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
buildRawPacketStatsSnapshot,
type RawPacketStatsSessionState,
} from '../utils/rawPacketStats';
function createSession(
overrides: Partial<RawPacketStatsSessionState> = {}
): 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);
});
});

View File

@@ -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<RawPacketStatsWindow, 'session'>,
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<string, number>;
}
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<typeof MeshCoreDecoder.decode>): 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<typeof MeshCoreDecoder.decode>
): Pick<RawPacketStatsObservation, 'sourceKey' | 'sourceLabel'> {
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<string, number> {
return new Map(labels.map((label) => [label, 0]));
}
function rankedBreakdown(counts: Map<string, number>, 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<string, number>([
['Direct', 0],
['1 hop', 0],
['2+ hops', 0],
]);
const neighborMap = new Map<string, NeighborStat>();
const rssiValues: number[] = [];
const rssiBucketCounts = new Map<string, number>([
['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<string, number>,
};
});
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,
};
}