From c30ed0b4bc242f6ae511240d45e3c77e620fb547 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 24 Feb 2026 08:24:34 -0800 Subject: [PATCH] Track advert path and use in mesh visualizer --- AGENTS.md | 11 ++ app/database.py | 14 ++ app/migrations.py | 35 ++++ app/models.py | 22 +++ app/packet_processor.py | 10 + app/repository.py | 109 +++++++++++ app/routers/contacts.py | 34 +++- frontend/src/api.ts | 8 + frontend/src/components/AGENTS.md | 17 ++ .../src/components/PacketVisualizer3D.tsx | 185 ++++++++++++++++-- frontend/src/test/api.test.ts | 13 ++ frontend/src/types.ts | 14 ++ tests/test_contacts_router.py | 46 ++++- tests/test_migrations.py | 32 +-- tests/test_repository.py | 62 +++++- 15 files changed, 579 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b9b9c27..b22efe6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,15 @@ Raw packet handling uses two identities by design: Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference. +## Repeater Advert Path Memory + +To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per repeater in a dedicated table (`repeater_advert_paths`). + +- This is independent of raw-packet payload deduplication. +- Paths are keyed per repeater + path, with `heard_count`, `first_seen`, and `last_seen`. +- Only the N most recent unique paths are retained per repeater (currently 10). +- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data. + ## Data Flow ### Incoming Messages @@ -259,7 +268,9 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected | | POST | `/api/radio/reconnect` | Manual radio reconnection | | GET | `/api/contacts` | List contacts | +| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all repeaters | | GET | `/api/contacts/{key}` | Get contact by public key or prefix | +| GET | `/api/contacts/{key}/advert-paths` | List recent unique advert paths for one repeater | | POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) | | DELETE | `/api/contacts/{key}` | Delete contact | | POST | `/api/contacts/sync` | Pull from radio | diff --git a/app/database.py b/app/database.py index 6f886e1..bb754fb 100644 --- a/app/database.py +++ b/app/database.py @@ -59,6 +59,18 @@ CREATE TABLE IF NOT EXISTS raw_packets ( FOREIGN KEY (message_id) REFERENCES messages(id) ); +CREATE TABLE IF NOT EXISTS repeater_advert_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repeater_key TEXT NOT NULL, + path_hex TEXT NOT NULL, + path_len INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + heard_count INTEGER NOT NULL DEFAULT 1, + UNIQUE(repeater_key, path_hex), + FOREIGN KEY (repeater_key) REFERENCES contacts(public_key) +); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(type, conversation_key); CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at); CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe @@ -66,6 +78,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash); CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio); +CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent + ON repeater_advert_paths(repeater_key, last_seen DESC); """ diff --git a/app/migrations.py b/app/migrations.py index d7b41ac..0bd2af7 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -184,6 +184,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int: await set_version(conn, 21) applied += 1 + # Migration 22: Track recent unique advert paths per repeater + if version < 22: + logger.info("Applying migration 22: add repeater_advert_paths table") + await _migrate_022_add_repeater_advert_paths(conn) + await set_version(conn, 22) + applied += 1 + if applied > 0: logger.info( "Applied %d migration(s), schema now at version %d", applied, await get_version(conn) @@ -1283,3 +1290,31 @@ async def _migrate_021_enforce_min_advert_interval(conn: aiosqlite.Connection) - ) await conn.commit() logger.debug("Clamped advert_interval to minimum 3600 seconds") + + +async def _migrate_022_add_repeater_advert_paths(conn: aiosqlite.Connection) -> None: + """ + Create table for recent unique advert paths per repeater. + + This keeps path diversity for repeater advertisements without changing the + existing payload-hash raw packet dedup policy. + """ + await conn.execute(""" + CREATE TABLE IF NOT EXISTS repeater_advert_paths ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repeater_key TEXT NOT NULL, + path_hex TEXT NOT NULL, + path_len INTEGER NOT NULL, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + heard_count INTEGER NOT NULL DEFAULT 1, + UNIQUE(repeater_key, path_hex), + FOREIGN KEY (repeater_key) REFERENCES contacts(public_key) + ) + """) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_repeater_advert_paths_recent " + "ON repeater_advert_paths(repeater_key, last_seen DESC)" + ) + await conn.commit() + logger.debug("Ensured repeater_advert_paths table and indexes exist") diff --git a/app/models.py b/app/models.py index 0b731e0..33acd28 100644 --- a/app/models.py +++ b/app/models.py @@ -72,6 +72,28 @@ class CreateContactRequest(BaseModel): CONTACT_TYPE_REPEATER = 2 +class RepeaterAdvertPath(BaseModel): + """A unique advert path observed for a repeater.""" + + path: str = Field(description="Hex-encoded routing path (empty string for direct)") + path_len: int = Field(description="Number of hops in the path") + next_hop: str | None = Field( + default=None, description="First hop toward us (2-char hex), or null for direct" + ) + first_seen: int = Field(description="Unix timestamp of first observation") + last_seen: int = Field(description="Unix timestamp of most recent observation") + heard_count: int = Field(description="Number of times this unique path was heard") + + +class RepeaterAdvertPathSummary(BaseModel): + """Recent unique advertisement paths for a single repeater.""" + + repeater_key: str = Field(description="Repeater public key (64-char hex)") + paths: list[RepeaterAdvertPath] = Field( + default_factory=list, description="Most recent unique advert paths" + ) + + class Channel(BaseModel): key: str = Field(description="Channel key (32-char hex)") name: str diff --git a/app/packet_processor.py b/app/packet_processor.py index edf143f..4e537f4 100644 --- a/app/packet_processor.py +++ b/app/packet_processor.py @@ -34,6 +34,7 @@ from app.repository import ( ContactRepository, MessageRepository, RawPacketRepository, + RepeaterAdvertPathRepository, ) from app.websocket import broadcast_error, broadcast_event @@ -688,6 +689,15 @@ async def _process_advertisement( advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) ) + # Keep recent unique advert paths for repeaters to improve frontend identity hints. + if contact_type == CONTACT_TYPE_REPEATER: + await RepeaterAdvertPathRepository.record_observation( + repeater_key=advert.public_key.lower(), + path_hex=new_path_hex, + timestamp=timestamp, + max_paths_per_repeater=10, + ) + contact_data = { "public_key": advert.public_key.lower(), "name": advert.name, diff --git a/app/repository.py b/app/repository.py index 9bf2a37..fdb9f77 100644 --- a/app/repository.py +++ b/app/repository.py @@ -15,6 +15,8 @@ from app.models import ( Favorite, Message, MessagePath, + RepeaterAdvertPath, + RepeaterAdvertPathSummary, ) logger = logging.getLogger(__name__) @@ -241,6 +243,113 @@ class ContactRepository: return [ContactRepository._row_to_contact(row) for row in rows] +class RepeaterAdvertPathRepository: + """Repository for recent unique repeater advertisement paths.""" + + @staticmethod + def _row_to_path(row) -> RepeaterAdvertPath: + path = row["path_hex"] or "" + next_hop = path[:2].lower() if len(path) >= 2 else None + return RepeaterAdvertPath( + path=path, + path_len=row["path_len"], + next_hop=next_hop, + first_seen=row["first_seen"], + last_seen=row["last_seen"], + heard_count=row["heard_count"], + ) + + @staticmethod + async def record_observation( + repeater_key: str, path_hex: str, timestamp: int, max_paths_per_repeater: int = 10 + ) -> None: + """ + Upsert a unique advert path observation for a repeater and prune to N most recent. + """ + if max_paths_per_repeater < 1: + max_paths_per_repeater = 1 + + normalized_key = repeater_key.lower() + normalized_path = path_hex.lower() + path_len = len(normalized_path) // 2 + + await db.conn.execute( + """ + INSERT INTO repeater_advert_paths + (repeater_key, path_hex, path_len, first_seen, last_seen, heard_count) + VALUES (?, ?, ?, ?, ?, 1) + ON CONFLICT(repeater_key, path_hex) DO UPDATE SET + last_seen = MAX(repeater_advert_paths.last_seen, excluded.last_seen), + path_len = excluded.path_len, + heard_count = repeater_advert_paths.heard_count + 1 + """, + (normalized_key, normalized_path, path_len, timestamp, timestamp), + ) + + # Keep only the N most recent unique paths per repeater. + await db.conn.execute( + """ + DELETE FROM repeater_advert_paths + WHERE repeater_key = ? + AND path_hex NOT IN ( + SELECT path_hex + FROM repeater_advert_paths + WHERE repeater_key = ? + ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC + LIMIT ? + ) + """, + (normalized_key, normalized_key, max_paths_per_repeater), + ) + await db.conn.commit() + + @staticmethod + async def get_recent_for_repeater( + repeater_key: str, limit: int = 10 + ) -> list[RepeaterAdvertPath]: + cursor = await db.conn.execute( + """ + SELECT path_hex, path_len, first_seen, last_seen, heard_count + FROM repeater_advert_paths + WHERE repeater_key = ? + ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC + LIMIT ? + """, + (repeater_key.lower(), limit), + ) + rows = await cursor.fetchall() + return [RepeaterAdvertPathRepository._row_to_path(row) for row in rows] + + @staticmethod + async def get_recent_for_all_repeaters( + limit_per_repeater: int = 10, + ) -> list[RepeaterAdvertPathSummary]: + cursor = await db.conn.execute( + """ + SELECT repeater_key, path_hex, path_len, first_seen, last_seen, heard_count + FROM repeater_advert_paths + ORDER BY repeater_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC + """ + ) + rows = await cursor.fetchall() + + grouped: dict[str, list[RepeaterAdvertPath]] = {} + for row in rows: + repeater_key = row["repeater_key"] + paths = grouped.get(repeater_key) + if paths is None: + paths = [] + grouped[repeater_key] = paths + if len(paths) >= limit_per_repeater: + continue + paths.append(RepeaterAdvertPathRepository._row_to_path(row)) + + return [ + RepeaterAdvertPathSummary(repeater_key=repeater_key, paths=paths) + for repeater_key, paths in grouped.items() + ] + + class ChannelRepository: @staticmethod async def upsert(key: str, name: str, is_hashtag: bool = False, on_radio: bool = False) -> None: diff --git a/app/routers/contacts.py b/app/routers/contacts.py index f50993e..79f0bed 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -16,13 +16,20 @@ from app.models import ( Contact, CreateContactRequest, NeighborInfo, + RepeaterAdvertPath, + RepeaterAdvertPathSummary, TelemetryRequest, TelemetryResponse, TraceResponse, ) from app.packet_processor import start_historical_dm_decryption from app.radio import radio_manager -from app.repository import AmbiguousPublicKeyPrefixError, ContactRepository, MessageRepository +from app.repository import ( + AmbiguousPublicKeyPrefixError, + ContactRepository, + MessageRepository, + RepeaterAdvertPathRepository, +) if TYPE_CHECKING: from meshcore.events import Event @@ -182,6 +189,16 @@ async def list_contacts( return await ContactRepository.get_all(limit=limit, offset=offset) +@router.get("/repeaters/advert-paths", response_model=list[RepeaterAdvertPathSummary]) +async def list_repeater_advert_paths( + limit_per_repeater: int = Query(default=10, ge=1, le=50), +) -> list[RepeaterAdvertPathSummary]: + """List recent unique advert paths for all repeaters.""" + return await RepeaterAdvertPathRepository.get_recent_for_all_repeaters( + limit_per_repeater=limit_per_repeater + ) + + @router.post("", response_model=Contact) async def create_contact( request: CreateContactRequest, background_tasks: BackgroundTasks @@ -265,6 +282,21 @@ async def get_contact(public_key: str) -> Contact: return await _resolve_contact_or_404(public_key) +@router.get("/{public_key}/advert-paths", response_model=list[RepeaterAdvertPath]) +async def get_contact_advert_paths( + public_key: str, + limit: int = Query(default=10, ge=1, le=50), +) -> list[RepeaterAdvertPath]: + """List recent unique advert paths for a single repeater contact.""" + contact = await _resolve_contact_or_404(public_key) + if contact.type != CONTACT_TYPE_REPEATER: + raise HTTPException( + status_code=400, + detail=f"Contact is not a repeater (type={contact.type}, expected {CONTACT_TYPE_REPEATER})", + ) + return await RepeaterAdvertPathRepository.get_recent_for_repeater(contact.public_key, limit) + + @router.post("/sync") async def sync_contacts_from_radio() -> dict: """Sync contacts from the radio to the database.""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ee7f396..95ba171 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -12,6 +12,8 @@ import type { MigratePreferencesResponse, RadioConfig, RadioConfigUpdate, + RepeaterAdvertPath, + RepeaterAdvertPathSummary, StatisticsResponse, TelemetryResponse, TraceResponse, @@ -94,6 +96,12 @@ export const api = { // Contacts getContacts: (limit = 100, offset = 0) => fetchJson(`/contacts?limit=${limit}&offset=${offset}`), + getRepeaterAdvertPaths: (limitPerRepeater = 10) => + fetchJson( + `/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}` + ), + getContactAdvertPaths: (publicKey: string, limit = 10) => + fetchJson(`/contacts/${publicKey}/advert-paths?limit=${limit}`), deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { method: 'DELETE', diff --git a/frontend/src/components/AGENTS.md b/frontend/src/components/AGENTS.md index e060430..8f60e4a 100644 --- a/frontend/src/components/AGENTS.md +++ b/frontend/src/components/AGENTS.md @@ -174,6 +174,22 @@ function resolveNode(source, isRepeater, showAmbiguous): string | null { When only a 1-byte prefix is known (from packet path bytes), the node is marked ambiguous and shown with a `?` prefix and gray styling. However, if the node is identified as a repeater (via advert or path hop), it shows blue regardless of ambiguity. +### Advert-Path Identity Hints + +**Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to. + +**Solution:** The backend tracks recent unique advertisement paths per repeater in `repeater_advert_paths` (see root `AGENTS.md` § "Repeater Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix. + +**Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires: +- At least 2 matching observations (stronger-than-trivial evidence) +- The top candidate's match score must be at least 2x the runner-up's + +When a winner is found, the ambiguous node gets a `probableIdentity` label (the likely repeater's name) and the display name updates accordingly. The remaining candidates are listed as "Other possible" in the tooltip. + +**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name. + +**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden). + ### Traffic Pattern Splitting (Experimental) **Problem:** Multiple physical repeaters can share the same 1-byte prefix (collision). Since packet paths only contain 1-byte hashes, we can't directly distinguish them. However, traffic patterns provide a heuristic. @@ -292,6 +308,7 @@ function buildPath(parsed, packet, myPrefix): string[] { | -------------------------- | ------- | --------------------------------------------------------- | | Ambiguous repeaters | On | Show nodes when only partial prefix known | | Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders | +| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters | | Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) | | Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) | | Let 'em drift | On | Continuous layout optimization | diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx index 05b230e..52770d4 100644 --- a/frontend/src/components/PacketVisualizer3D.tsx +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -16,7 +16,14 @@ import { } from 'd3-force-3d'; 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 { api } from '../api'; +import { + CONTACT_TYPE_REPEATER, + type Contact, + type RawPacket, + type RadioConfig, + type RepeaterAdvertPathSummary, +} from '../types'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; import { Checkbox } from './ui/checkbox'; import { @@ -50,6 +57,7 @@ interface GraphNode extends SimulationNodeDatum3D { isAmbiguous: boolean; lastActivity: number; lastSeen?: number | null; + probableIdentity?: string | null; ambiguousNames?: string[]; } @@ -110,8 +118,10 @@ interface UseVisualizerData3DOptions { packets: RawPacket[]; contacts: Contact[]; config: RadioConfig | null; + repeaterAdvertPaths: RepeaterAdvertPathSummary[]; showAmbiguousPaths: boolean; showAmbiguousNodes: boolean; + useAdvertPathHints: boolean; splitAmbiguousByTraffic: boolean; chargeStrength: number; letEmDrift: boolean; @@ -133,8 +143,10 @@ function useVisualizerData3D({ packets, contacts, config, + repeaterAdvertPaths, showAmbiguousPaths, showAmbiguousNodes, + useAdvertPathHints, splitAmbiguousByTraffic, chargeStrength, letEmDrift, @@ -182,6 +194,15 @@ function useVisualizerData3D({ return { byPrefix12, byName, byPrefix }; }, [contacts]); + const advertPathIndex = useMemo(() => { + const byRepeater = new Map(); + for (const summary of repeaterAdvertPaths) { + const key = summary.repeater_key.slice(0, 12).toLowerCase(); + byRepeater.set(key, summary.paths); + } + return { byRepeater }; + }, [repeaterAdvertPaths]); + // Keep refs in sync with props useEffect(() => { speedMultiplierRef.current = particleSpeedMultiplier; @@ -304,7 +325,13 @@ function useVisualizerData3D({ trafficPatternsRef.current.clear(); setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); syncSimulation(); - }, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic, syncSimulation]); + }, [ + showAmbiguousPaths, + showAmbiguousNodes, + useAdvertPathHints, + splitAmbiguousByTraffic, + syncSimulation, + ]); const addNode = useCallback( ( @@ -312,13 +339,15 @@ function useVisualizerData3D({ name: string | null, type: NodeType, isAmbiguous: boolean, + probableIdentity?: string | null, ambiguousNames?: string[], lastSeen?: number | null ) => { const existing = nodesRef.current.get(id); if (existing) { existing.lastActivity = Date.now(); - if (name && !existing.name) existing.name = name; + if (name) existing.name = name; + if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity; if (ambiguousNames) existing.ambiguousNames = ambiguousNames; if (lastSeen !== undefined) existing.lastSeen = lastSeen; } else { @@ -332,6 +361,7 @@ function useVisualizerData3D({ type, isAmbiguous, lastActivity: Date.now(), + probableIdentity, lastSeen, ambiguousNames, x: r * Math.sin(phi) * Math.cos(theta), @@ -378,6 +408,48 @@ function useVisualizerData3D({ } }, []); + const pickLikelyRepeaterByAdvertPath = useCallback( + (candidates: Contact[], nextPrefix: string | null) => { + const nextHop = nextPrefix?.toLowerCase() ?? null; + const scored = candidates + .map((candidate) => { + const prefix12 = candidate.public_key.slice(0, 12).toLowerCase(); + const paths = advertPathIndex.byRepeater.get(prefix12) ?? []; + let matchScore = 0; + let totalScore = 0; + + for (const path of paths) { + totalScore += path.heard_count; + const pathNextHop = path.next_hop?.toLowerCase() ?? null; + if (pathNextHop === nextHop) { + matchScore += path.heard_count; + } + } + + return { candidate, matchScore, totalScore }; + }) + .filter((entry) => entry.totalScore > 0) + .sort( + (a, b) => + b.matchScore - a.matchScore || + b.totalScore - a.totalScore || + a.candidate.public_key.localeCompare(b.candidate.public_key) + ); + + if (scored.length === 0) return null; + + const top = scored[0]; + const second = scored[1] ?? null; + + // Require stronger-than-trivial evidence and a clear winner. + if (top.matchScore < 2) return null; + if (second && top.matchScore < second.matchScore * 2) return null; + + return top.candidate; + }, + [advertPathIndex] + ); + const resolveNode = useCallback( ( source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, @@ -397,6 +469,7 @@ function useVisualizerData3D({ getNodeType(contact), false, undefined, + undefined, contact?.last_seen ); return nodeId; @@ -407,11 +480,19 @@ function useVisualizerData3D({ if (contact) { const nodeId = contact.public_key.slice(0, 12).toLowerCase(); if (myPrefix && nodeId === myPrefix) return 'self'; - addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); + addNode( + nodeId, + contact.name, + getNodeType(contact), + false, + undefined, + undefined, + contact.last_seen + ); return nodeId; } const nodeId = `name:${source.value}`; - addNode(nodeId, source.value, 'client', false); + addNode(nodeId, source.value, 'client', false, undefined); return nodeId; } @@ -421,7 +502,15 @@ function useVisualizerData3D({ if (contact) { const nodeId = contact.public_key.slice(0, 12).toLowerCase(); if (myPrefix && nodeId === myPrefix) return 'self'; - addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen); + addNode( + nodeId, + contact.name, + getNodeType(contact), + false, + undefined, + undefined, + contact.last_seen + ); return nodeId; } @@ -433,7 +522,7 @@ function useVisualizerData3D({ if (filtered.length === 1) { const c = filtered[0]; const nodeId = c.public_key.slice(0, 12).toLowerCase(); - addNode(nodeId, c.name, getNodeType(c), false, undefined, c.last_seen); + addNode(nodeId, c.name, getNodeType(c), false, undefined, undefined, c.last_seen); return nodeId; } @@ -446,6 +535,20 @@ function useVisualizerData3D({ let nodeId = `?${source.value.toLowerCase()}`; let displayName = source.value.toUpperCase(); + let probableIdentity: string | null = null; + let ambiguousNames = names.length > 0 ? names : undefined; + + if (useAdvertPathHints && isRepeater && trafficContext) { + const likely = pickLikelyRepeaterByAdvertPath(filtered, trafficContext.nextPrefix); + if (likely) { + const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase(); + probableIdentity = likelyName; + displayName = likelyName; + ambiguousNames = filtered + .filter((c) => c.public_key !== likely.public_key) + .map((c) => c.name || c.public_key.slice(0, 8)); + } + } if (splitAmbiguousByTraffic && isRepeater && trafficContext) { const prefix = source.value.toLowerCase(); @@ -465,7 +568,9 @@ function useVisualizerData3D({ if (analysis.shouldSplit && trafficContext.nextPrefix) { const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase(); nodeId = `?${prefix}:>${nextShort}`; - displayName = `${source.value.toUpperCase()}:>${nextShort}`; + if (!probableIdentity) { + displayName = `${source.value.toUpperCase()}:>${nextShort}`; + } } } } @@ -475,7 +580,8 @@ function useVisualizerData3D({ displayName, isRepeater ? 'repeater' : 'client', true, - names.length > 0 ? names : undefined, + probableIdentity, + ambiguousNames, lastSeen ); return nodeId; @@ -484,7 +590,13 @@ function useVisualizerData3D({ return null; }, - [contactIndex, addNode, splitAmbiguousByTraffic] + [ + contactIndex, + addNode, + useAdvertPathHints, + pickLikelyRepeaterByAdvertPath, + splitAmbiguousByTraffic, + ] ); const buildPath = useCallback( @@ -892,6 +1004,7 @@ export function PacketVisualizer3D({ // Options const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true); const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false); + const [useAdvertPathHints, setUseAdvertPathHints] = useState(true); const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true); const [chargeStrength, setChargeStrength] = useState(-200); const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC); @@ -900,6 +1013,31 @@ export function PacketVisualizer3D({ const [showControls, setShowControls] = useState(true); const [autoOrbit, setAutoOrbit] = useState(false); const [pruneStaleNodes, setPruneStaleNodes] = useState(false); + const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); + + useEffect(() => { + let cancelled = false; + + async function loadRepeaterAdvertPaths() { + try { + const data = await api.getRepeaterAdvertPaths(10); + if (!cancelled) { + setRepeaterAdvertPaths(data); + } + } catch (error) { + if (!cancelled) { + // Best-effort hinting; keep visualizer fully functional without this data. + console.debug('Failed to load repeater advert path hints', error); + setRepeaterAdvertPaths([]); + } + } + } + + loadRepeaterAdvertPaths(); + return () => { + cancelled = true; + }; + }, [contacts.length]); // Hover & click-to-pin const [hoveredNodeId, setHoveredNodeId] = useState(null); @@ -914,8 +1052,10 @@ export function PacketVisualizer3D({ packets, contacts, config, + repeaterAdvertPaths, showAmbiguousPaths, showAmbiguousNodes, + useAdvertPathHints, splitAmbiguousByTraffic, chargeStrength, letEmDrift, @@ -1228,9 +1368,7 @@ export function PacketVisualizer3D({ if (nd.labelDiv.style.color !== labelColor) { nd.labelDiv.style.color = labelColor; } - const labelText = node.isAmbiguous - ? node.id - : node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); if (nd.labelDiv.textContent !== labelText) { nd.labelDiv.textContent = labelText; } @@ -1535,6 +1673,19 @@ export function PacketVisualizer3D({ Show ambiguous sender/recipient +