mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-12 03:33:03 +02:00
Merge branch 'main' into perf/virtualize-message-list
This commit is contained in:
+1
-15
@@ -16,7 +16,6 @@ import {
|
||||
useBrowserNotifications,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { toast } from './components/ui/sonner';
|
||||
import { AppShell } from './components/AppShell';
|
||||
@@ -27,13 +26,7 @@ import { RichPayloadProvider } from './contexts/RichPayloadContext';
|
||||
import { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type {
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
Conversation,
|
||||
Message,
|
||||
RawPacket,
|
||||
} from './types';
|
||||
import type { BulkCreateHashtagChannelsResult, Channel, Conversation, Message } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
@@ -83,7 +76,6 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
@@ -99,7 +91,6 @@ export function App() {
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const pushSubscription = usePush();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
@@ -403,7 +394,6 @@ export function App() {
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
reconcileOnReconnect,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
@@ -424,7 +414,6 @@ export function App() {
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
});
|
||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||
clearConversationMessages();
|
||||
@@ -568,8 +557,6 @@ export function App() {
|
||||
activeConversation,
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
messages: sortedMessages,
|
||||
@@ -706,7 +693,6 @@ export function App() {
|
||||
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
channels,
|
||||
onChannelCreate: handleCreateCrackedChannel,
|
||||
};
|
||||
|
||||
@@ -13,12 +13,10 @@ import type {
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -38,8 +36,6 @@ interface ConversationPaneProps {
|
||||
activeConversation: Conversation | null;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
rawPackets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
@@ -126,8 +122,6 @@ export function ConversationPane({
|
||||
activeConversation,
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
@@ -219,7 +213,6 @@ export function ConversationPane({
|
||||
<MapView
|
||||
contacts={contacts}
|
||||
focusedKey={activeConversation.mapFocusKey}
|
||||
rawPackets={rawPackets}
|
||||
config={config}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
@@ -244,25 +237,13 @@ export function ConversationPane({
|
||||
if (activeConversation.type === 'visualizer') {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
|
||||
<VisualizerView
|
||||
packets={rawPackets}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
/>
|
||||
<VisualizerView contacts={contacts} channels={channels} config={config} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'raw') {
|
||||
return (
|
||||
<RawPacketFeedView
|
||||
packets={rawPackets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
return <RawPacketFeedView contacts={contacts} channels={channels} />;
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'search') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
|
||||
interface CrackedChannel {
|
||||
channelName: string;
|
||||
@@ -23,7 +24,6 @@ interface QueueItem {
|
||||
}
|
||||
|
||||
export interface CrackerPanelProps {
|
||||
packets: RawPacket[];
|
||||
channels: Channel[];
|
||||
onChannelCreate: (name: string, key: string) => Promise<void>;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
@@ -31,12 +31,12 @@ export interface CrackerPanelProps {
|
||||
}
|
||||
|
||||
export function CrackerPanel({
|
||||
packets,
|
||||
channels,
|
||||
onChannelCreate,
|
||||
onRunningChange,
|
||||
visible = false,
|
||||
}: CrackerPanelProps) {
|
||||
const packets = useRawPackets();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import type { Contact, RadioConfig } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
@@ -23,13 +23,13 @@ import {
|
||||
dedupeConsecutive,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
/** Public key of contact to focus on and open popup */
|
||||
focusedKey?: string | null;
|
||||
rawPackets?: RawPacket[];
|
||||
config?: RadioConfig | null;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
@@ -541,12 +541,12 @@ function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||
export function MapView({
|
||||
contacts,
|
||||
focusedKey,
|
||||
rawPackets,
|
||||
config,
|
||||
blockedKeys,
|
||||
blockedNames,
|
||||
onSelectContact,
|
||||
}: MapViewProps) {
|
||||
const rawPackets = useRawPackets();
|
||||
const [sinceId, setSinceId] = useState<MapSinceId>(getSavedSinceId);
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [nowSec, setNowSec] = useState(() => Date.now() / 1000);
|
||||
@@ -911,7 +911,15 @@ export function MapView({
|
||||
<span>{infoLabel}</span>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 md:justify-end">
|
||||
{!showPackets && (
|
||||
<>
|
||||
// Grouped and labelled like the "Since" filter below it. The colour
|
||||
// dots are aria-hidden, so without this the legend is unlabelled to
|
||||
// assistive tech — and its bucket names collide with the identically
|
||||
// named filter chips for anything selecting by text.
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1"
|
||||
role="group"
|
||||
aria-label="Marker recency legend"
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
@@ -944,7 +952,7 @@ export function MapView({
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{showPackets && (
|
||||
<>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
||||
import { useRawPacketStatsSession, useRawPackets } from '../stores/rawPacketStore';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -197,8 +198,6 @@ function FeedFilterControls({
|
||||
}
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
}
|
||||
@@ -588,12 +587,9 @@ function TimelineChart({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketFeedView({
|
||||
packets,
|
||||
rawPacketStatsSession,
|
||||
contacts,
|
||||
channels,
|
||||
}: RawPacketFeedViewProps) {
|
||||
export function RawPacketFeedView({ contacts, channels }: RawPacketFeedViewProps) {
|
||||
const packets = useRawPackets();
|
||||
const rawPacketStatsSession = useRawPacketStatsSession();
|
||||
const [statsOpen, setStatsOpen] = useState(() =>
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(min-width: 768px)').matches
|
||||
|
||||
@@ -7,15 +7,16 @@ import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
|
||||
interface VisualizerViewProps {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
}
|
||||
|
||||
export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) {
|
||||
export function VisualizerView({ contacts, channels, config }: VisualizerViewProps) {
|
||||
const packets = useRawPackets();
|
||||
const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed);
|
||||
const [paneFullScreen, setPaneFullScreen] = useState(false);
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
|
||||
@@ -13,12 +13,76 @@ import {
|
||||
} from 'recharts';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { api } from '../../api';
|
||||
import type { StatisticsResponse } from '../../types';
|
||||
import type { RegionScopeStats, StatisticsResponse } from '../../types';
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional flood-scope adoption. Deliberately shows fractions rather than bare
|
||||
* percentages: with adoption this sparse, "3 of 117" communicates the sample
|
||||
* size that "2.6%" hides.
|
||||
*/
|
||||
function RegionScopeStatsPanel({ stats }: { stats: RegionScopeStats }) {
|
||||
// Corrupt RF captures land in the packet table with random headers, some of
|
||||
// which claim to be region-scoped. At or below the measured floor there is
|
||||
// nothing to report but noise, so withhold the percentage and say so.
|
||||
const floor = stats.false_positive_floor;
|
||||
const withinNoise = stats.scoped_messages <= floor;
|
||||
// Real-world scoping is currently rare enough that the share rounds to "0.0%",
|
||||
// which reads as a broken widget. Below that resolution the fraction alone is
|
||||
// the honest presentation.
|
||||
const showTrafficPct = stats.total_messages > 0 && !withinNoise && stats.scoped_pct >= 0.05;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Region Scope (24h)</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground mb-3">
|
||||
How much local traffic uses regional flood scoping. Traffic covers all channel messages
|
||||
heard, including channels you have no key for; senders only counts channels you can decrypt,
|
||||
so the two use different denominators and will not match.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">Scoped messages</span>
|
||||
<span className="font-medium text-right">
|
||||
{stats.scoped_messages.toLocaleString()} of {stats.total_messages.toLocaleString()}
|
||||
{showTrafficPct && (
|
||||
<span className="text-muted-foreground"> ({formatPercent(stats.scoped_pct)})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">Senders using regions</span>
|
||||
<span className="font-medium text-right">
|
||||
{stats.scoped_senders.toLocaleString()} of {stats.total_senders.toLocaleString()}
|
||||
{stats.total_senders > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
({formatPercent(stats.scoped_senders_pct)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{floor > 0 && stats.scoped_messages > 0 && (
|
||||
<p className="text-[0.8125rem] text-muted-foreground mt-2">
|
||||
{withinNoise
|
||||
? `Scoped message count is at or below the estimated false-positive floor (${floor.toFixed(0)}) from corrupt packet captures, so it is not evidence of regional adoption.`
|
||||
: `Includes an estimated ${floor.toFixed(0)} false positives from corrupt packet captures.`}{' '}
|
||||
The sender count is unaffected — it requires successful decryption.
|
||||
</p>
|
||||
)}
|
||||
{stats.total_messages === 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
No channel messages heard in the last 24 hours.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
@@ -404,6 +468,11 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Region Scope */}
|
||||
<RegionScopeStatsPanel stats={stats.region_scope_24h} />
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -11,4 +11,3 @@ export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { toast } from '../components/ui/sonner';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import { clearRawPackets, MAX_RAW_PACKETS, recordRawPacket } from '../stores/rawPacketStore';
|
||||
import { emitStatusDotPulse } from '../utils/statusDotPulse';
|
||||
import type {
|
||||
Channel,
|
||||
@@ -27,7 +27,6 @@ interface UseRealtimeAppStateArgs {
|
||||
prevHealthRef: MutableRefObject<HealthStatus | null>;
|
||||
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
|
||||
fetchConfig: () => void | Promise<void>;
|
||||
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
|
||||
reconcileOnReconnect: () => void;
|
||||
refreshUnreads: () => Promise<void>;
|
||||
setChannels: Dispatch<SetStateAction<Channel[]>>;
|
||||
@@ -58,7 +57,7 @@ interface UseRealtimeAppStateArgs {
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
/** Buffer cap override. Defaults to the store's own cap; tests use it to force eviction. */
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
@@ -87,7 +86,6 @@ export function useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
reconcileOnReconnect,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
@@ -108,8 +106,7 @@ export function useRealtimeAppState({
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
maxRawPackets = 500,
|
||||
maxRawPackets = MAX_RAW_PACKETS,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
(updated: Channel) => {
|
||||
@@ -180,7 +177,7 @@ export function useRealtimeAppState({
|
||||
});
|
||||
},
|
||||
onReconnect: () => {
|
||||
setRawPackets([]);
|
||||
clearRawPackets();
|
||||
reconcileOnReconnect();
|
||||
refreshUnreads();
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
@@ -263,9 +260,8 @@ export function useRealtimeAppState({
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
recordRawPacketObservation?.(packet);
|
||||
emitStatusDotPulse(packet.payload_type);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
recordRawPacket(packet, maxRawPackets);
|
||||
},
|
||||
onMessageAcked: (
|
||||
messageId: number,
|
||||
@@ -291,7 +287,6 @@ export function useRealtimeAppState({
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
recordMessageEvent,
|
||||
recordRawPacketObservation,
|
||||
receiveMessageAck,
|
||||
observeMessage,
|
||||
refreshUnreads,
|
||||
@@ -301,7 +296,6 @@ export function useRealtimeAppState({
|
||||
setChannels,
|
||||
setContacts,
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
notifyIncomingMessage,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import {
|
||||
MAX_RAW_PACKET_STATS_OBSERVATIONS,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
/**
|
||||
* Live radio traffic arrives continuously — every packet the node overhears, not
|
||||
* just our own conversations. Holding that stream in App state re-rendered the whole
|
||||
* tree (including the message list) on every packet, which made typing crawl in
|
||||
* conversations with a lot of history.
|
||||
*
|
||||
* The stream lives here instead, outside React, so only the views that actually read
|
||||
* packets (map, visualizer, raw feed, cracker) re-render when one arrives.
|
||||
*/
|
||||
|
||||
export const MAX_RAW_PACKETS = 500;
|
||||
|
||||
function createStatsSession(): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: Date.now(),
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
|
||||
let packets: RawPacket[] = [];
|
||||
let statsSession: RawPacketStatsSessionState = createStatsSession();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function observePacket(
|
||||
session: RawPacketStatsSessionState,
|
||||
packet: RawPacket
|
||||
): RawPacketStatsSessionState {
|
||||
const observation = summarizeRawPacketForStats(packet);
|
||||
if (
|
||||
session.observations.some(
|
||||
(candidate) => candidate.observationKey === observation.observationKey
|
||||
)
|
||||
) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const observations = [...session.observations, observation];
|
||||
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
|
||||
return {
|
||||
...session,
|
||||
totalObservedPackets: session.totalObservedPackets + 1,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
|
||||
return {
|
||||
...session,
|
||||
totalObservedPackets: session.totalObservedPackets + 1,
|
||||
trimmedObservationCount: session.trimmedObservationCount + overflow,
|
||||
observations: observations.slice(overflow),
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one observed packet into both the rolling buffer and the session stats. */
|
||||
export function recordRawPacket(packet: RawPacket, maxPackets: number = MAX_RAW_PACKETS): void {
|
||||
const nextPackets = appendRawPacketUnique(packets, packet, maxPackets);
|
||||
const nextStats = observePacket(statsSession, packet);
|
||||
if (nextPackets === packets && nextStats === statsSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
packets = nextPackets;
|
||||
statsSession = nextStats;
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the buffered packets on reconnect — we may have missed traffic while offline.
|
||||
* Session stats deliberately survive: they describe the whole observation session.
|
||||
*/
|
||||
export function clearRawPackets(): void {
|
||||
if (packets.length === 0) {
|
||||
return;
|
||||
}
|
||||
packets = [];
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Full reset, including session stats. Used by tests to isolate cases. */
|
||||
export function resetRawPacketStore(): void {
|
||||
packets = [];
|
||||
statsSession = createStatsSession();
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a specific stream/stats snapshot. Exists so tests can drive the views
|
||||
* from fixed fixtures — including stats shapes that a live packet feed would take
|
||||
* minutes to produce — without reintroducing prop drilling through the app tree.
|
||||
*/
|
||||
export function seedRawPacketStore(next: {
|
||||
packets?: RawPacket[];
|
||||
statsSession?: RawPacketStatsSessionState;
|
||||
}): void {
|
||||
// Copy rather than alias. The whole store rests on "a snapshot is immutable, and its
|
||||
// identity changes only when its contents do". Holding the caller's array would let
|
||||
// them mutate the live snapshot in place, and because useSyncExternalStore compares
|
||||
// snapshots with Object.is, React would then bail out of every subsequent render —
|
||||
// leaving the UI permanently disagreeing with getRawPackets() and no way to tell why.
|
||||
if (next.packets) {
|
||||
packets = [...next.packets];
|
||||
}
|
||||
if (next.statsSession) {
|
||||
statsSession = {
|
||||
...next.statsSession,
|
||||
observations: [...next.statsSession.observations],
|
||||
};
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
export function getRawPackets(): RawPacket[] {
|
||||
return packets;
|
||||
}
|
||||
|
||||
export function getRawPacketStatsSession(): RawPacketStatsSessionState {
|
||||
return statsSession;
|
||||
}
|
||||
|
||||
export function useRawPackets(): RawPacket[] {
|
||||
return useSyncExternalStore(subscribe, getRawPackets);
|
||||
}
|
||||
|
||||
export function useRawPacketStatsSession(): RawPacketStatsSessionState {
|
||||
return useSyncExternalStore(subscribe, getRawPacketStatsSession);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* The invariant this store exists to protect: nothing on the chat render path may
|
||||
* subscribe to the raw packet stream.
|
||||
*
|
||||
* The original bug was not that the chat view read packets — it never did. It was that
|
||||
* the stream lived in `App` state, and `App` is an *ancestor* of `MessageList`. Nothing
|
||||
* on that path is memoized, so every overheard packet re-rendered the whole message list
|
||||
* regardless of which props it actually received.
|
||||
*
|
||||
* That means the regression can only be caught by mounting the real ancestor chain
|
||||
* (`App` → `AppShell` → `ConversationPane` → `MessageList`). A test that renders
|
||||
* `ConversationPane` on its own cannot see it: the offending subscription lives above.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
api: {
|
||||
getRadioConfig: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
getUndecryptedPacketCount: vi.fn(),
|
||||
getChannels: vi.fn(),
|
||||
getContacts: vi.fn(),
|
||||
getHealth: vi.fn(),
|
||||
},
|
||||
hookFns: {
|
||||
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({ api: mocks.api }));
|
||||
|
||||
vi.mock('../useWebSocket', () => ({ useWebSocket: vi.fn() }));
|
||||
|
||||
vi.mock('../contexts/PushSubscriptionContext', () => ({
|
||||
usePush: () => ({
|
||||
isSupported: false,
|
||||
isSubscribed: false,
|
||||
currentSubscriptionId: null,
|
||||
allSubscriptions: [],
|
||||
pushConversations: [],
|
||||
loading: false,
|
||||
subscribe: vi.fn(async () => null),
|
||||
unsubscribe: vi.fn(async () => {}),
|
||||
toggleConversation: vi.fn(async () => {}),
|
||||
isConversationPushEnabled: () => false,
|
||||
deleteSubscription: vi.fn(async () => {}),
|
||||
testPush: vi.fn(async () => {}),
|
||||
refreshSubscriptions: vi.fn(async () => []),
|
||||
refreshConversations: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../hooks')>();
|
||||
return {
|
||||
...actual,
|
||||
useConversationMessages: () => ({
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
reloadCurrentConversation: vi.fn(),
|
||||
observeMessage: mocks.hookFns.observeMessage,
|
||||
receiveMessageAck: vi.fn(),
|
||||
reconcileOnReconnect: vi.fn(),
|
||||
renameConversationMessages: vi.fn(),
|
||||
removeConversationMessages: vi.fn(),
|
||||
clearConversationMessages: vi.fn(),
|
||||
}),
|
||||
useUnreadCounts: () => ({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
recordMessageEvent: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
removeConversationState: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
refreshUnreads: mocks.hookFns.refreshUnreads,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Mocked to keep the tree small and deterministic. None of them are mounted while a
|
||||
// chat conversation is active, so removing them cannot mask a chat-path subscription.
|
||||
vi.mock('../components/StatusBar', () => ({ StatusBar: () => <div data-testid="status-bar" /> }));
|
||||
vi.mock('../components/Sidebar', () => ({ Sidebar: () => <div data-testid="sidebar" /> }));
|
||||
vi.mock('../components/MessageList', () => ({ MessageList: mocks.messageList }));
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
MessageInput: React.forwardRef((_props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({ appendText: vi.fn(), focus: vi.fn() }));
|
||||
return <div data-testid="message-input" />;
|
||||
}),
|
||||
}));
|
||||
vi.mock('../components/NewMessageModal', () => ({ NewMessageModal: () => null }));
|
||||
vi.mock('../components/SettingsModal', () => ({
|
||||
SettingsModal: () => null,
|
||||
SETTINGS_SECTION_ORDER: ['radio'],
|
||||
SETTINGS_SECTION_LABELS: { radio: 'Radio' },
|
||||
}));
|
||||
vi.mock('../components/MapView', () => ({ MapView: () => null }));
|
||||
vi.mock('../components/VisualizerView', () => ({ VisualizerView: () => null }));
|
||||
vi.mock('../components/CrackerPanel', () => ({ CrackerPanel: () => null }));
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
Toaster: () => null,
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock('../utils/urlHash', () => ({
|
||||
parseHashConversation: () => null,
|
||||
parseHashSettingsSection: () => null,
|
||||
updateUrlHash: vi.fn(),
|
||||
pushUrlHash: vi.fn(),
|
||||
updateSettingsHash: vi.fn(),
|
||||
pushSettingsHash: vi.fn(),
|
||||
getSettingsHash: (section: string) => `#settings/${section}`,
|
||||
getMapFocusHash: () => '#map',
|
||||
}));
|
||||
|
||||
import { App } from '../App';
|
||||
import {
|
||||
getRawPackets,
|
||||
recordRawPacket,
|
||||
resetRawPacketStore,
|
||||
useRawPackets,
|
||||
} from '../stores/rawPacketStore';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
observation_id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const publicChannel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
describe('overheard packets and the chat render path', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRawPacketStore();
|
||||
mocks.api.getRadioConfig.mockResolvedValue({
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'TestNode',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
advert_interval: 0,
|
||||
last_advert_time: 0,
|
||||
flood_scope: '',
|
||||
known_regions: [],
|
||||
blocked_keys: [],
|
||||
blocked_names: [],
|
||||
});
|
||||
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
mocks.api.getHealth.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('does not re-render the message list when packets arrive', async () => {
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const rendersBefore = mocks.messageList.mock.calls.length;
|
||||
act(() => {
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }));
|
||||
}
|
||||
});
|
||||
|
||||
// Guards the assertion below against passing for the wrong reason
|
||||
expect(getRawPackets()).toHaveLength(25);
|
||||
expect(mocks.messageList.mock.calls.length).toBe(rendersBefore);
|
||||
});
|
||||
|
||||
it('re-renders the message list once per batch if an ancestor subscribes', async () => {
|
||||
// Negative control. Proves the assertion above can actually fail — without this, a
|
||||
// render counter that never increments would look identical to a passing test.
|
||||
//
|
||||
// The ancestor has to create the <App /> element inside its own render. Taking it as
|
||||
// a `children` prop would defeat the point: that element is built once by the caller,
|
||||
// so its identity never changes and React bails out of the subtree — the test would
|
||||
// pass while proving nothing.
|
||||
function SubscribingAncestor() {
|
||||
useRawPackets();
|
||||
return <App />;
|
||||
}
|
||||
|
||||
render(<SubscribingAncestor />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const rendersBefore = mocks.messageList.mock.calls.length;
|
||||
act(() => {
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(mocks.messageList.mock.calls.length).toBeGreaterThan(rendersBefore);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ describe('CrackerPanel', () => {
|
||||
});
|
||||
|
||||
it('allows clearing max length while editing', async () => {
|
||||
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
|
||||
render(<CrackerPanel channels={[]} onChannelCreate={vi.fn()} visible={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketFeedView } from '../components/RawPacketFeedView';
|
||||
import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
|
||||
@@ -108,17 +109,15 @@ function renderView({
|
||||
channels?: Channel[];
|
||||
rawPacketStatsSession?: RawPacketStatsSessionState;
|
||||
} = {}) {
|
||||
return render(
|
||||
<RawPacketFeedView
|
||||
packets={packets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
seedRawPacketStore({ packets, statsSession: rawPacketStatsSession });
|
||||
return render(<RawPacketFeedView contacts={contacts} channels={channels} />);
|
||||
}
|
||||
|
||||
describe('RawPacketFeedView', () => {
|
||||
beforeEach(() => {
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('RawPacketFeedView', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = renderView({
|
||||
renderView({
|
||||
packets: initialPackets,
|
||||
rawPacketStatsSession: initialSession,
|
||||
contacts: [],
|
||||
@@ -236,14 +235,7 @@ describe('RawPacketFeedView', () => {
|
||||
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={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
act(() => seedRawPacketStore({ packets: nextPackets, statsSession: initialSession }));
|
||||
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:30Z'));
|
||||
@@ -257,14 +249,7 @@ describe('RawPacketFeedView', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={nextSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
act(() => seedRawPacketStore({ packets: nextPackets, statsSession: nextSession }));
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import {
|
||||
clearRawPackets,
|
||||
getRawPacketStatsSession,
|
||||
getRawPackets,
|
||||
recordRawPacket,
|
||||
resetRawPacketStore,
|
||||
seedRawPacketStore,
|
||||
useRawPackets,
|
||||
} from '../stores/rawPacketStore';
|
||||
import { MAX_RAW_PACKET_STATS_OBSERVATIONS } from '../utils/rawPacketStats';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
RawPacket,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: mocks.messageList,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
observation_id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stats session already holding `count` distinct observations, for trim-boundary tests. */
|
||||
function sessionAtObservationCap(count: number): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: 1700000000000,
|
||||
totalObservedPackets: count,
|
||||
trimmedObservationCount: 0,
|
||||
observations: Array.from({ length: count }, (_, i) => ({
|
||||
observationKey: `seeded-${i}`,
|
||||
timestamp: 1700000000 + i,
|
||||
payloadType: 'GROUP_TEXT',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: null,
|
||||
snr: null,
|
||||
sourceKey: null,
|
||||
sourceLabel: null,
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const channel: Channel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Radio',
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
};
|
||||
|
||||
const health: HealthStatus = {
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'serial',
|
||||
database_size_mb: 1,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: channel.key,
|
||||
text: 'hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
|
||||
const rawPacketStatsSession: RawPacketStatsSessionState = {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function chatPaneProps(): any {
|
||||
return {
|
||||
activeConversation: { type: 'channel', id: channel.key, name: channel.name } as Conversation,
|
||||
contacts: [] as Contact[],
|
||||
channels: [channel],
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
unreadMarkerLastReadAt: undefined,
|
||||
targetMessageId: null,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onToggleMute: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||
onSelectConversation: vi.fn(),
|
||||
onOpenContactInfo: vi.fn(),
|
||||
onOpenChannelInfo: vi.fn(),
|
||||
onSenderClick: vi.fn(),
|
||||
onLoadOlder: vi.fn(async () => {}),
|
||||
onResendChannelMessage: vi.fn(async () => {}),
|
||||
onTargetReached: vi.fn(),
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('rawPacketStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
it('appends packets and counts them in the session stats', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
|
||||
recordRawPacket(createPacket({ id: 2, observation_id: 2 }));
|
||||
|
||||
expect(getRawPackets()).toHaveLength(2);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores a repeat of the same observation', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
|
||||
|
||||
expect(getRawPackets()).toHaveLength(1);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
|
||||
it('caps the buffer at the requested size, keeping the newest packets', () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }), 3);
|
||||
}
|
||||
|
||||
expect(getRawPackets().map((p) => p.id)).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('keeps session stats across a reconnect clear', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
|
||||
clearRawPackets();
|
||||
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
|
||||
it('notifies subscribed views when a packet arrives', () => {
|
||||
function PacketCount() {
|
||||
return <span data-testid="count">{useRawPackets().length}</span>;
|
||||
}
|
||||
render(<PacketCount />);
|
||||
expect(screen.getByTestId('count').textContent).toBe('0');
|
||||
|
||||
act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 })));
|
||||
|
||||
expect(screen.getByTestId('count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
/**
|
||||
* Asserted through a mounted subscriber rather than getRawPackets(), because the
|
||||
* failure mode is specifically a missing emit(): the module state would be correct
|
||||
* while every view kept rendering packets that no longer exist. On a quiet mesh the
|
||||
* next packet — and so the next repaint — can be minutes away.
|
||||
*/
|
||||
it('notifies subscribed views when the buffer is cleared on reconnect', () => {
|
||||
function PacketCount() {
|
||||
return <span data-testid="count">{useRawPackets().length}</span>;
|
||||
}
|
||||
act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 })));
|
||||
render(<PacketCount />);
|
||||
expect(screen.getByTestId('count').textContent).toBe('1');
|
||||
|
||||
act(() => clearRawPackets());
|
||||
|
||||
expect(screen.getByTestId('count').textContent).toBe('0');
|
||||
});
|
||||
|
||||
it('does not hand out a snapshot the seeding caller can still mutate', () => {
|
||||
const fixture = [createPacket({ id: 1, observation_id: 1 })];
|
||||
seedRawPacketStore({ packets: fixture });
|
||||
|
||||
fixture.push(createPacket({ id: 2, observation_id: 2 }));
|
||||
|
||||
// Aliasing the caller's array would mutate the live snapshot in place. Because
|
||||
// useSyncExternalStore compares snapshots with Object.is, the identity would not
|
||||
// change and React would bail out of every later render for good.
|
||||
expect(getRawPackets()).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* MAX_RAW_PACKET_STATS_OBSERVATIONS is 20k, far past what a test can reach by
|
||||
* recording, so the trim boundary is exercised by seeding a session that already
|
||||
* sits on it. Without this, both the `<=` comparison and the trimmed-count
|
||||
* arithmetic can be broken without any test noticing.
|
||||
*/
|
||||
it('retains exactly the observation cap before trimming starts', () => {
|
||||
seedRawPacketStore({
|
||||
statsSession: sessionAtObservationCap(MAX_RAW_PACKET_STATS_OBSERVATIONS - 1),
|
||||
});
|
||||
|
||||
recordRawPacket(createPacket({ id: 999999, observation_id: 999999 }));
|
||||
|
||||
const session = getRawPacketStatsSession();
|
||||
expect(session.observations).toHaveLength(MAX_RAW_PACKET_STATS_OBSERVATIONS);
|
||||
expect(session.trimmedObservationCount).toBe(0);
|
||||
});
|
||||
|
||||
it('trims the oldest observation once the cap is exceeded', () => {
|
||||
seedRawPacketStore({
|
||||
statsSession: sessionAtObservationCap(MAX_RAW_PACKET_STATS_OBSERVATIONS),
|
||||
});
|
||||
|
||||
recordRawPacket(createPacket({ id: 999999, observation_id: 999999 }));
|
||||
|
||||
const session = getRawPacketStatsSession();
|
||||
expect(session.observations).toHaveLength(MAX_RAW_PACKET_STATS_OBSERVATIONS);
|
||||
expect(session.trimmedObservationCount).toBe(1);
|
||||
// The evicted entry is the oldest, not the newest
|
||||
expect(session.observations[0].observationKey).not.toBe('seeded-0');
|
||||
});
|
||||
|
||||
/**
|
||||
* The reason this store exists: overheard traffic used to live in App state, so every
|
||||
* packet re-rendered the whole message list. That cost scales with history length and
|
||||
* made typing in a busy channel crawl.
|
||||
*/
|
||||
it('does not re-render the chat message list when packets arrive', () => {
|
||||
render(<ConversationPane {...chatPaneProps()} />);
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
|
||||
const rendersAfterMount = mocks.messageList.mock.calls.length;
|
||||
act(() => {
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(getRawPackets()).toHaveLength(25);
|
||||
expect(mocks.messageList.mock.calls.length).toBe(rendersAfterMount);
|
||||
});
|
||||
});
|
||||
@@ -777,6 +777,15 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 120,
|
||||
scoped_messages: 40,
|
||||
scoped_pct: 33.3,
|
||||
false_positive_floor: 2,
|
||||
total_senders: 12,
|
||||
scoped_senders: 3,
|
||||
scoped_senders_pct: 25,
|
||||
},
|
||||
packets_per_hour_72h: [
|
||||
{ timestamp: 1711792800, count: 12 },
|
||||
{ timestamp: 1711796400, count: 8 },
|
||||
@@ -824,6 +833,146 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
// Fractions, not bare percentages — the sample size matters at this sparsity
|
||||
expect(screen.getByText(/40 of 120/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 of 12/)).toBeInTheDocument();
|
||||
// 40 scoped is well above the floor of 2, so the percentage is shown
|
||||
expect(screen.getByText(/33\.3%/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/at or below the estimated false-positive floor/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discloses the false-positive floor and withholds a sub-0.1% scoped share', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [],
|
||||
contact_count: 0,
|
||||
repeater_count: 0,
|
||||
channel_count: 0,
|
||||
total_packets: 0,
|
||||
decrypted_packets: 0,
|
||||
undecrypted_packets: 0,
|
||||
total_dms: 0,
|
||||
total_channel_messages: 0,
|
||||
total_outgoing: 0,
|
||||
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 0,
|
||||
single_byte: 0,
|
||||
double_byte: 0,
|
||||
triple_byte: 0,
|
||||
single_byte_pct: 0,
|
||||
double_byte_pct: 0,
|
||||
triple_byte_pct: 0,
|
||||
},
|
||||
// Mirrors real-world data: 70 "scoped" packets against a measured floor of
|
||||
// 60 is corrupt-capture noise, not adoption.
|
||||
region_scope_24h: {
|
||||
total_messages: 391757,
|
||||
scoped_messages: 70,
|
||||
scoped_pct: 0.0179,
|
||||
false_positive_floor: 60.3,
|
||||
total_senders: 117,
|
||||
scoped_senders: 3,
|
||||
scoped_senders_pct: 2.56,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
coverage_seconds: 0,
|
||||
latest_noise_floor_dbm: null,
|
||||
latest_timestamp: null,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockStats), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 70 scoped sits just above the 60.3 floor, so most of it is corrupt captures
|
||||
expect(screen.getByText(/Includes an estimated 60 false positives/)).toBeInTheDocument();
|
||||
// 0.0179% would render as a meaningless "0.0%", so the share is withheld
|
||||
expect(screen.queryByText(/0\.0%/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/70 of 391,757/)).toBeInTheDocument();
|
||||
// ...but the decryption-backed sender figure still stands
|
||||
expect(screen.getByText(/3 of 117/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2\.6%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports scoped traffic as noise when it is at or below the floor', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [],
|
||||
contact_count: 0,
|
||||
repeater_count: 0,
|
||||
channel_count: 0,
|
||||
total_packets: 0,
|
||||
decrypted_packets: 0,
|
||||
undecrypted_packets: 0,
|
||||
total_dms: 0,
|
||||
total_channel_messages: 0,
|
||||
total_outgoing: 0,
|
||||
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 0,
|
||||
single_byte: 0,
|
||||
double_byte: 0,
|
||||
triple_byte: 0,
|
||||
single_byte_pct: 0,
|
||||
double_byte_pct: 0,
|
||||
triple_byte_pct: 0,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 5000,
|
||||
scoped_messages: 12,
|
||||
scoped_pct: 0.24,
|
||||
false_positive_floor: 20,
|
||||
total_senders: 40,
|
||||
scoped_senders: 0,
|
||||
scoped_senders_pct: 0,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
coverage_seconds: 0,
|
||||
latest_noise_floor_dbm: null,
|
||||
latest_timestamp: null,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockStats), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/at or below the estimated false-positive floor \(20\)/)
|
||||
).toBeInTheDocument();
|
||||
// Percentage withheld even though 0.24% would round visibly — it is noise
|
||||
expect(screen.queryByText(/0\.2%/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||
@@ -850,6 +999,15 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 0,
|
||||
scoped_messages: 0,
|
||||
scoped_pct: 0,
|
||||
false_positive_floor: 0,
|
||||
total_senders: 0,
|
||||
scoped_senders: 0,
|
||||
scoped_senders_pct: 0,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
|
||||
@@ -2,6 +2,12 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useRealtimeAppState } from '../hooks/useRealtimeAppState';
|
||||
import {
|
||||
getRawPacketStatsSession,
|
||||
getRawPackets,
|
||||
resetRawPacketStore,
|
||||
seedRawPacketStore,
|
||||
} from '../stores/rawPacketStore';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -32,6 +38,18 @@ const publicChannel: Channel = {
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const rawPacketFixture: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 2,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
id: 7,
|
||||
type: 'PRIV',
|
||||
@@ -50,7 +68,6 @@ const incomingDm: Message = {
|
||||
|
||||
function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppState>[0]> = {}) {
|
||||
const setHealth = vi.fn();
|
||||
const setRawPackets = vi.fn();
|
||||
const setChannels = vi.fn();
|
||||
const setContacts = vi.fn();
|
||||
|
||||
@@ -59,7 +76,6 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
prevHealthRef: { current: null as HealthStatus | null },
|
||||
setHealth,
|
||||
fetchConfig: vi.fn(),
|
||||
setRawPackets,
|
||||
reconcileOnReconnect: vi.fn(),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
setChannels,
|
||||
@@ -84,7 +100,6 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
},
|
||||
fns: {
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
setChannels,
|
||||
setContacts,
|
||||
},
|
||||
@@ -94,6 +109,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
describe('useRealtimeAppState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRawPacketStore();
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
});
|
||||
|
||||
@@ -125,6 +141,8 @@ describe('useRealtimeAppState', () => {
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
seedRawPacketStore({ packets: [rawPacketFixture] });
|
||||
|
||||
act(() => {
|
||||
result.current.onReconnect?.();
|
||||
});
|
||||
@@ -134,7 +152,7 @@ describe('useRealtimeAppState', () => {
|
||||
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
|
||||
});
|
||||
@@ -168,6 +186,8 @@ describe('useRealtimeAppState', () => {
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
seedRawPacketStore({ packets: [rawPacketFixture] });
|
||||
|
||||
act(() => {
|
||||
result.current.onReconnect?.();
|
||||
});
|
||||
@@ -177,7 +197,7 @@ describe('useRealtimeAppState', () => {
|
||||
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
|
||||
});
|
||||
@@ -285,18 +305,8 @@ describe('useRealtimeAppState', () => {
|
||||
});
|
||||
|
||||
it('appends raw packets using observation identity dedup', () => {
|
||||
const { args, fns } = createRealtimeArgs();
|
||||
const packet: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 2,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
const { args } = createRealtimeArgs();
|
||||
const packet = rawPacketFixture;
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
@@ -304,6 +314,7 @@ describe('useRealtimeAppState', () => {
|
||||
result.current.onRawPacket?.(packet);
|
||||
});
|
||||
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(getRawPackets()).toEqual([packet]);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VisualizerView } from '../components/VisualizerView';
|
||||
import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
// The 3D scene needs WebGL, which jsdom does not provide.
|
||||
@@ -26,17 +27,12 @@ function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
describe('VisualizerView packet feed', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
it('opens the packet analyzer when a feed packet is clicked', () => {
|
||||
render(
|
||||
<VisualizerView
|
||||
packets={[createPacket({ id: 7, observation_id: 21 })]}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
config={null}
|
||||
/>
|
||||
);
|
||||
seedRawPacketStore({ packets: [createPacket({ id: 7, observation_id: 21 })] });
|
||||
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
|
||||
|
||||
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
|
||||
|
||||
@@ -47,7 +43,7 @@ describe('VisualizerView packet feed', () => {
|
||||
});
|
||||
|
||||
it('does not render the analyzer until a packet is selected', () => {
|
||||
render(<VisualizerView packets={[]} contacts={[]} channels={[]} config={null} />);
|
||||
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
|
||||
|
||||
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -676,6 +676,24 @@ interface PacketsPerHourBucket {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional flood-scope adoption over the last 24h. Two views with different
|
||||
* denominators that will not agree — traffic spans all channels including
|
||||
* undecryptable ones (so it carries a false-positive floor from corrupt RF
|
||||
* captures), while senders requires decryption and is therefore noise-free but
|
||||
* limited to channels we hold keys for.
|
||||
*/
|
||||
export interface RegionScopeStats {
|
||||
total_messages: number;
|
||||
scoped_messages: number;
|
||||
scoped_pct: number;
|
||||
/** Estimated false positives in scoped_messages. At or below this = not adoption. */
|
||||
false_positive_floor: number;
|
||||
total_senders: number;
|
||||
scoped_senders: number;
|
||||
scoped_senders_pct: number;
|
||||
}
|
||||
|
||||
export interface StatisticsResponse {
|
||||
busiest_channels_24h: BusyChannel[];
|
||||
contact_count: number;
|
||||
@@ -699,6 +717,7 @@ export interface StatisticsResponse {
|
||||
double_byte_pct: number;
|
||||
triple_byte_pct: number;
|
||||
};
|
||||
region_scope_24h: RegionScopeStats;
|
||||
packets_per_hour_72h: PacketsPerHourBucket[];
|
||||
noise_floor_24h: NoiseFloorHistoryStats;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user