mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-01 06:22:54 +02:00
Be better about identity resolution for stats view
This commit is contained in:
@@ -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<string, NeighborStat>();
|
||||
|
||||
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 (
|
||||
<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 ? (
|
||||
{sortedItems.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
{sortedItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
|
||||
|
||||
@@ -297,6 +297,54 @@ describe('RawPacketFeedView', () => {
|
||||
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: [
|
||||
|
||||
@@ -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<RawPacketStatsSessionState> = {}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user