Fix dedupe for frontend raw packet delivery

This commit is contained in:
Jack Kingsman
2026-02-16 20:46:43 -08:00
parent 56fde32970
commit 0e25bd2281
12 changed files with 188 additions and 20 deletions
+2 -10
View File
@@ -49,6 +49,7 @@ const CrackerPanel = lazy(() =>
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
import { appendRawPacketUnique } from './utils/rawPacketIdentity';
import { cn } from '@/lib/utils';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
@@ -297,16 +298,7 @@ export function App() {
});
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => {
if (prev.some((p) => p.id === packet.id)) {
return prev;
}
const updated = [...prev, packet];
if (updated.length > MAX_RAW_PACKETS) {
return updated.slice(-MAX_RAW_PACKETS);
}
return updated;
});
setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
@@ -17,6 +17,7 @@ import {
import type { SimulationLinkDatum } from 'd3-force';
import { PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { Checkbox } from './ui/checkbox';
import {
type NodeType,
@@ -145,7 +146,7 @@ function useVisualizerData3D({
const linksRef = useRef<Map<string, GraphLink>>(new Map());
const particlesRef = useRef<Particle[]>([]);
const simulationRef = useRef<Simulation3D<GraphNode, GraphLink> | null>(null);
const processedRef = useRef<Set<number>>(new Set());
const processedRef = useRef<Set<string>>(new Set());
const pendingRef = useRef<Map<string, PendingPacket>>(new Map());
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const trafficPatternsRef = useRef<Map<string, RepeaterTrafficData>>(new Map());
@@ -592,8 +593,9 @@ function useVisualizerData3D({
const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null;
for (const packet of packets) {
if (processedRef.current.has(packet.id)) continue;
processedRef.current.add(packet.id);
const observationKey = getRawPacketObservationKey(packet);
if (processedRef.current.has(observationKey)) continue;
processedRef.current.add(observationKey);
newProcessed++;
if (processedRef.current.size > 1000) {
+5 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { cn } from '@/lib/utils';
interface RawPacketListProps {
@@ -207,7 +208,10 @@ export function RawPacketList({ packets }: RawPacketListProps) {
return (
<div className="h-full overflow-y-auto p-4 flex flex-col gap-2" ref={listRef}>
{sortedPackets.map(({ packet, decoded }) => (
<div key={packet.id} className="py-2 px-3 bg-card rounded-md border border-border/50">
<div
key={getRawPacketObservationKey(packet)}
className="py-2 px-3 bg-card rounded-md border border-border/50"
>
<div className="flex items-center gap-2">
{/* Route type badge */}
<span
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import type { RawPacket } from '../types';
import { appendRawPacketUnique, getRawPacketObservationKey } from '../utils/rawPacketIdentity';
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
return {
id: 1,
timestamp: 1700000000,
data: '010203',
payload_type: 'ACK',
snr: null,
rssi: null,
decrypted: false,
decrypted_info: null,
...overrides,
};
}
describe('getRawPacketObservationKey', () => {
it('uses observation_id when present', () => {
const packet = createPacket({ id: 99, observation_id: 7 });
expect(getRawPacketObservationKey(packet)).toBe('obs-7');
});
it('falls back to db id when observation_id is missing', () => {
const packet = createPacket({ id: 42 });
expect(getRawPacketObservationKey(packet)).toBe('db-42');
});
});
describe('appendRawPacketUnique', () => {
it('keeps path-diverse observations with same db id', () => {
const first = createPacket({ id: 5, observation_id: 100, data: 'aa' });
const second = createPacket({ id: 5, observation_id: 101, data: 'bb' });
const afterFirst = appendRawPacketUnique([], first, 500);
const afterSecond = appendRawPacketUnique(afterFirst, second, 500);
expect(afterSecond).toHaveLength(2);
expect(afterSecond[0].observation_id).toBe(100);
expect(afterSecond[1].observation_id).toBe(101);
});
it('drops exact duplicate observations', () => {
const packet = createPacket({ id: 5, observation_id: 100 });
const afterFirst = appendRawPacketUnique([], packet, 500);
const afterSecond = appendRawPacketUnique(afterFirst, packet, 500);
expect(afterSecond).toHaveLength(1);
});
it('dedupes by db id when observation_id is absent', () => {
const first = createPacket({ id: 11, observation_id: undefined });
const second = createPacket({ id: 11, observation_id: undefined, timestamp: 1700000001 });
const afterFirst = appendRawPacketUnique([], first, 500);
const afterSecond = appendRawPacketUnique(afterFirst, second, 500);
expect(afterSecond).toHaveLength(1);
});
it('enforces max packet cap', () => {
const packets = [
createPacket({ id: 1, observation_id: 1 }),
createPacket({ id: 2, observation_id: 2 }),
createPacket({ id: 3, observation_id: 3 }),
];
let state: RawPacket[] = [];
state = appendRawPacketUnique(state, packets[0], 2);
state = appendRawPacketUnique(state, packets[1], 2);
state = appendRawPacketUnique(state, packets[2], 2);
expect(state).toHaveLength(2);
expect(state[0].observation_id).toBe(2);
expect(state[1].observation_id).toBe(3);
});
});
+2
View File
@@ -98,6 +98,8 @@ export interface Conversation {
export interface RawPacket {
id: number;
/** Per-observation WS identity (unique per RF arrival, may be absent in older payloads) */
observation_id?: number;
timestamp: number;
data: string; // hex
payload_type: string;
+31
View File
@@ -0,0 +1,31 @@
import type { RawPacket } from '../types';
/**
* Distinguish real-time RF observations from storage identity.
* observation_id is emitted per WS event; id is the DB row identity fallback.
*/
export function getRawPacketObservationKey(
packet: Pick<RawPacket, 'id' | 'observation_id'>
): string {
if (packet.observation_id !== undefined && packet.observation_id !== null) {
return `obs-${packet.observation_id}`;
}
return `db-${packet.id}`;
}
export function appendRawPacketUnique(
prev: RawPacket[],
packet: RawPacket,
maxPackets: number
): RawPacket[] {
const packetKey = getRawPacketObservationKey(packet);
if (prev.some((p) => getRawPacketObservationKey(p) === packetKey)) {
return prev;
}
const updated = [...prev, packet];
if (updated.length > maxPackets) {
return updated.slice(-maxPackets);
}
return updated;
}