Track advert path and use in mesh visualizer

Track advert path and use in mesh visualizer
This commit is contained in:
Jack Kingsman
2026-02-24 14:58:21 -08:00
15 changed files with 580 additions and 33 deletions

View File

@@ -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<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
fetchJson<RepeaterAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<RepeaterAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',

View File

@@ -174,6 +174,23 @@ 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 +309,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 |

View File

@@ -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<string, RepeaterAdvertPathSummary['paths']>();
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<RepeaterAdvertPathSummary[]>([]);
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<string | null>(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
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={useAdvertPathHints}
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
disabled={!showAmbiguousPaths}
/>
<span
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes"
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
>
Use repeater advert-path identity hints
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={splitAmbiguousByTraffic}
@@ -1702,9 +1853,13 @@ export function PacketVisualizer3D({
Type: {node.type}
{node.isAmbiguous ? ' (ambiguous)' : ''}
</div>
{node.probableIdentity && (
<div className="text-muted-foreground">Probably: {node.probableIdentity}</div>
)}
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
<div className="text-muted-foreground">
Possible: {node.ambiguousNames.join(', ')}
{node.probableIdentity ? 'Other possible: ' : 'Possible: '}
{node.ambiguousNames.join(', ')}
</div>
)}
{neighbors.length > 0 && (

View File

@@ -106,6 +106,19 @@ describe('fetchJson (via api methods)', () => {
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts?limit=100&offset=0');
});
it('builds repeater advert path endpoint query', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
});
});
describe('error handling', () => {

View File

@@ -52,6 +52,20 @@ export interface Contact {
last_read_at: number | null;
}
export interface RepeaterAdvertPath {
path: string;
path_len: number;
next_hop: string | null;
first_seen: number;
last_seen: number;
heard_count: number;
}
export interface RepeaterAdvertPathSummary {
repeater_key: string;
paths: RepeaterAdvertPath[];
}
export interface Channel {
key: string;
name: string;