mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add fancy metrics view for packet feed. Closes #75.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
532
frontend/src/components/RawPacketFeedView.tsx
Normal file
532
frontend/src/components/RawPacketFeedView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal file
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
266
frontend/src/test/rawPacketFeedView.test.tsx
Normal file
266
frontend/src/test/rawPacketFeedView.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
108
frontend/src/test/rawPacketStats.test.ts
Normal file
108
frontend/src/test/rawPacketStats.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
466
frontend/src/utils/rawPacketStats.ts
Normal file
466
frontend/src/utils/rawPacketStats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user