diff --git a/frontend/src/components/RawPacketFeedView.tsx b/frontend/src/components/RawPacketFeedView.tsx index e9dfcd6..6c43850 100644 --- a/frontend/src/components/RawPacketFeedView.tsx +++ b/frontend/src/components/RawPacketFeedView.tsx @@ -77,12 +77,16 @@ function formatRssi(value: number | null): string { return value === null ? '-' : `${Math.round(value)} dBm`; } -function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null { +function normalizeResolvableSourceKey(sourceKey: string): string { + return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey; +} + +function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null { if (!sourceKey || sourceKey.startsWith('name:')) { return null; } - const normalizedSourceKey = sourceKey.toLowerCase(); + const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase(); const matches = contacts.filter((contact) => contact.public_key.toLowerCase().startsWith(normalizedSourceKey) ); @@ -90,7 +94,14 @@ function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): str return null; } - const contact = matches[0]; + return matches[0]; +} + +function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null { + const contact = resolveContact(sourceKey, contacts); + if (!contact) { + return null; + } return getContactDisplayName(contact.name, contact.public_key, contact.last_advert); } @@ -101,11 +112,46 @@ function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat }; } +function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] { + const merged = new Map(); + + for (const item of items) { + const contact = resolveContact(item.key, contacts); + const canonicalKey = contact?.public_key ?? item.key; + const resolvedLabel = + contact != null + ? getContactDisplayName(contact.name, contact.public_key, contact.last_advert) + : item.label; + const existing = merged.get(canonicalKey); + + if (!existing) { + merged.set(canonicalKey, { + ...item, + key: canonicalKey, + label: resolvedLabel, + }); + continue; + } + + existing.count += item.count; + existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen); + existing.bestRssi = + existing.bestRssi === null + ? item.bestRssi + : item.bestRssi === null + ? existing.bestRssi + : Math.max(existing.bestRssi, item.bestRssi); + existing.label = resolvedLabel; + } + + return Array.from(merged.values()); +} + function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean { if (item.key.startsWith('name:')) { return true; } - return resolveContactLabel(item.key, contacts) !== null; + return resolveContact(item.key, contacts) !== null; } function formatStrongestPacketDetail( @@ -219,14 +265,29 @@ function NeighborList({ mode: 'heard' | 'signal' | 'recent'; contacts: Contact[]; }) { + const mergedItems = mergeResolvedNeighbors(items, contacts); + const sortedItems = [...mergedItems].sort((a, b) => { + if (mode === 'heard') { + return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label); + } + if (mode === 'signal') { + return ( + (b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) || + b.count - a.count || + a.label.localeCompare(b.label) + ); + } + return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label); + }); + return (

{title}

- {items.length === 0 ? ( + {sortedItems.length === 0 ? (

{emptyLabel}

) : (
- {items.map((item) => ( + {sortedItems.map((item) => (
{ expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0); }); + it('collapses uniquely resolved hash buckets into the same visible contact row', () => { + const alphaContact = createContact({ + public_key: 'aa11bb22cc33' + '0'.repeat(52), + name: 'Alpha', + }); + + renderView({ + rawPacketStatsSession: createSession({ + totalObservedPackets: 2, + observations: [ + { + observationKey: 'obs-1', + timestamp: 1_700_000_000, + payloadType: 'TextMessage', + routeType: 'Direct', + decrypted: true, + rssi: -70, + snr: 6, + sourceKey: 'hash1:AA', + sourceLabel: 'AA', + pathTokenCount: 0, + pathSignature: null, + }, + { + observationKey: 'obs-2', + timestamp: 1_700_000_030, + payloadType: 'TextMessage', + routeType: 'Direct', + decrypted: true, + rssi: -67, + snr: 7, + sourceKey: alphaContact.public_key.toUpperCase(), + sourceLabel: alphaContact.public_key.slice(0, 12).toUpperCase(), + pathTokenCount: 0, + pathSignature: null, + }, + ], + }), + contacts: [alphaContact], + }); + + 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); + expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument(); + }); + it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => { renderView({ packets: [ diff --git a/frontend/src/test/rawPacketStats.test.ts b/frontend/src/test/rawPacketStats.test.ts index e70b412..d9d8b9d 100644 --- a/frontend/src/test/rawPacketStats.test.ts +++ b/frontend/src/test/rawPacketStats.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest'; import { buildRawPacketStatsSnapshot, + summarizeRawPacketForStats, type RawPacketStatsSessionState, } from '../utils/rawPacketStats'; +import type { RawPacket } from '../types'; + +const TEXT_MESSAGE_PACKET = '09046F17C47ED00A13E16AB5B94B1CC2D1A5059C6E5A6253C60D'; function createSession( overrides: Partial = {} @@ -75,6 +79,49 @@ function createSession( } describe('buildRawPacketStatsSnapshot', () => { + it('prefers decrypted contact identity over one-byte sourceHash for stats bucketing', () => { + const packet: RawPacket = { + id: 1, + observation_id: 10, + timestamp: 1_700_000_000, + data: TEXT_MESSAGE_PACKET, + payload_type: 'TextMessage', + snr: 4, + rssi: -72, + decrypted: true, + decrypted_info: { + channel_name: null, + sender: 'Alpha', + channel_key: null, + contact_key: '0a'.repeat(32), + }, + }; + + const summary = summarizeRawPacketForStats(packet); + + expect(summary.sourceKey).toBe('0A'.repeat(32)); + expect(summary.sourceLabel).toBe('Alpha'); + }); + + it('tags unresolved one-byte source hashes so they do not collide with full contact keys', () => { + const packet: RawPacket = { + id: 2, + observation_id: 11, + timestamp: 1_700_000_000, + data: TEXT_MESSAGE_PACKET, + payload_type: 'TextMessage', + snr: 4, + rssi: -72, + decrypted: false, + decrypted_info: null, + }; + + const summary = summarizeRawPacketForStats(packet); + + expect(summary.sourceKey).toBe('hash1:0A'); + expect(summary.sourceLabel).toBe('0A'); + }); + it('computes counts, rankings, and rolling-window coverage from session observations', () => { const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000); diff --git a/frontend/src/utils/rawPacketStats.ts b/frontend/src/utils/rawPacketStats.ts index a6f12be..66d9d30 100644 --- a/frontend/src/utils/rawPacketStats.ts +++ b/frontend/src/utils/rawPacketStats.ts @@ -163,10 +163,17 @@ function getSourceInfo( case PayloadType.TextMessage: case PayloadType.Request: case PayloadType.Response: { + const contactKey = packet.decrypted_info?.contact_key?.toUpperCase() ?? null; + if (contactKey) { + return { + sourceKey: contactKey, + sourceLabel: packet.decrypted_info?.sender || toSourceLabel(contactKey), + }; + } const sourceHash = (decoded.payload.decoded as { sourceHash?: string }).sourceHash; if (!sourceHash) return { sourceKey: null, sourceLabel: null }; return { - sourceKey: sourceHash.toUpperCase(), + sourceKey: `hash1:${sourceHash.toUpperCase()}`, sourceLabel: sourceHash.toUpperCase(), }; }