mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 05:22:45 +02:00
Track advert path and use in mesh visualizer
Track advert path and use in mesh visualizer
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user