From 134cf92c6db9f7aedb23cbab0af2b9504b3d5f47 Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Fri, 21 Nov 2025 19:20:46 +0100
Subject: [PATCH] update node detail hydration for traces (#490)
* update node detail hydration for traces
* cover missing unit test vectors
---
.../assets/js/app/__tests__/node-page.test.js | 112 ++++++++++++++++++
web/public/assets/js/app/node-page.js | 103 ++++++++++++++--
2 files changed, 205 insertions(+), 10 deletions(-)
diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js
index 0d39b04..4c54d92 100644
--- a/web/public/assets/js/app/__tests__/node-page.test.js
+++ b/web/public/assets/js/app/__tests__/node-page.test.js
@@ -35,11 +35,14 @@ const {
formatSnr,
padTwo,
normalizeNodeId,
+ cloneRoleIndex,
registerRoleCandidate,
lookupRole,
lookupNeighborDetails,
seedNeighborRoleIndex,
buildNeighborRoleIndex,
+ collectTraceNodeFetchMap,
+ buildTraceRoleIndex,
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
@@ -230,6 +233,68 @@ test('buildNeighborRoleIndex fetches missing neighbor metadata from the API', as
assert.equal(allyMetadata.shortName, 'ALLY-API');
});
+test('buildTraceRoleIndex hydrates hop metadata using node lookups', async () => {
+ const traces = [{ src: '!src', hops: [42], dest: '!dest' }];
+ const calls = [];
+ const fetchImpl = async url => {
+ calls.push(url);
+ if (url.includes('0000002a')) {
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return { node_id: '!hop', node_num: 42, short_name: 'HOPR', long_name: 'Hop Route', role: 'ROUTER' };
+ },
+ };
+ }
+ if (url.includes('/api/nodes/!dest')) {
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return { node_id: '!dest', short_name: 'DESTN', long_name: 'Destination', role: 'CLIENT' };
+ },
+ };
+ }
+ return { ok: false, status: 404, async json() { return {}; } };
+ };
+ const baseIndex = cloneRoleIndex({
+ byId: new Map([['!src', 'CLIENT']]),
+ byNum: new Map(),
+ detailsById: new Map([['!src', { shortName: 'SRC1', role: 'CLIENT' }]]),
+ detailsByNum: new Map(),
+ });
+ const fetchMap = collectTraceNodeFetchMap(traces, baseIndex);
+ assert.equal(fetchMap.size, 2);
+ const roleIndex = await buildTraceRoleIndex(traces, baseIndex, { fetchImpl });
+ const hopDetails = lookupNeighborDetails(roleIndex, { numericId: 42 });
+ const destDetails = lookupNeighborDetails(roleIndex, { identifier: '!dest' });
+ assert.equal(hopDetails.shortName, 'HOPR');
+ assert.equal(hopDetails.longName, 'Hop Route');
+ assert.equal(destDetails.shortName, 'DESTN');
+ assert.equal(destDetails.longName, 'Destination');
+ assert.equal(calls.some(url => url.includes('%21src')), false);
+});
+
+test('cloneRoleIndex builds isolated maps and collectTraceNodeFetchMap handles numeric placeholders', () => {
+ const baseIndex = {
+ byId: new Map([['!known', 'CLIENT']]),
+ byNum: new Map([[7, 'ROUTER']]),
+ detailsById: new Map([['!known', { shortName: 'KNWN' }]]),
+ detailsByNum: new Map([[7, { shortName: 'SEVN' }]]),
+ };
+ const clone = cloneRoleIndex(baseIndex);
+ assert.notStrictEqual(clone.byId, baseIndex.byId);
+ assert.notStrictEqual(clone.byNum, baseIndex.byNum);
+ assert.notStrictEqual(clone.detailsById, baseIndex.detailsById);
+ assert.notStrictEqual(clone.detailsByNum, baseIndex.detailsByNum);
+
+ const fetchMap = collectTraceNodeFetchMap([{ src: 7, hops: [88], dest: null }], clone);
+ assert.equal(fetchMap.has('!00000058'), true);
+ assert.equal(fetchMap.get('!00000058'), '!00000058');
+ assert.equal(fetchMap.has('!known'), false);
+});
+
test('renderSingleNodeTable renders a condensed table for the node', () => {
const node = {
shortName: 'NODE',
@@ -448,6 +513,53 @@ test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
assert.equal(html.includes('node-detail__table'), true);
});
+test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async () => {
+ const reference = { nodeId: '!origin' };
+ const calledUrls = [];
+ const fetchImpl = async url => {
+ calledUrls.push(url);
+ if (url.startsWith('/api/messages/')) {
+ return { ok: true, status: 200, async json() { return []; } };
+ }
+ if (url.startsWith('/api/traces/')) {
+ return {
+ ok: true,
+ status: 200,
+ async json() {
+ return [{ src: '!origin', hops: ['!relay'], dest: '!target' }];
+ },
+ };
+ }
+ if (url.includes('/api/nodes/!relay')) {
+ return { ok: true, status: 200, async json() { return { node_id: '!relay', short_name: 'RLY1', role: 'REPEATER' }; } };
+ }
+ if (url.includes('/api/nodes/!target')) {
+ return { ok: true, status: 200, async json() { return { node_id: '!target', short_name: 'TGT1', long_name: 'Trace Target', role: 'CLIENT' }; } };
+ }
+ return { ok: true, status: 200, async json() { return { node_id: '!origin', short_name: 'ORIG', role: 'CLIENT' }; } };
+ };
+ const refreshImpl = async () => ({
+ nodeId: '!origin',
+ nodeNum: 7,
+ shortName: 'ORIG',
+ longName: 'Origin Node',
+ role: 'CLIENT',
+ neighbors: [],
+ rawSources: { node: { node_id: '!origin', role: 'CLIENT', short_name: 'ORIG' } },
+ });
+
+ const html = await fetchNodeDetailHtml(reference, {
+ refreshImpl,
+ fetchImpl,
+ renderShortHtml: short => `${short}`,
+ });
+
+ assert.equal(calledUrls.some(url => url.includes('/api/nodes/!relay')), true);
+ assert.equal(calledUrls.some(url => url.includes('/api/nodes/!target')), true);
+ assert.equal(html.includes('RLY1'), true);
+ assert.equal(html.includes('TGT1'), true);
+});
+
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
await assert.rejects(
() => fetchNodeDetailHtml({}, { refreshImpl: async () => ({}) }),
diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js
index 7ee920c..a23159a 100644
--- a/web/public/assets/js/app/node-page.js
+++ b/web/public/assets/js/app/node-page.js
@@ -1275,6 +1275,22 @@ function registerRoleCandidate(
}
}
+/**
+ * Clone an existing role index into fresh map instances.
+ *
+ * @param {Object|null|undefined} index Original role index maps.
+ * @returns {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}}
+ * Cloned maps with identical entries.
+ */
+function cloneRoleIndex(index) {
+ return {
+ byId: index?.byId instanceof Map ? new Map(index.byId) : new Map(),
+ byNum: index?.byNum instanceof Map ? new Map(index.byNum) : new Map(),
+ detailsById: index?.detailsById instanceof Map ? new Map(index.detailsById) : new Map(),
+ detailsByNum: index?.detailsByNum instanceof Map ? new Map(index.detailsByNum) : new Map(),
+ };
+}
+
/**
* Resolve a role from the provided index using identifier or numeric keys.
*
@@ -1431,14 +1447,15 @@ function seedNeighborRoleIndex(index, neighbors) {
}
/**
- * Fetch missing neighbor role assignments using the nodes API.
+ * Fetch node metadata for the supplied identifiers and merge it into the role index.
*
- * @param {{byId: Map, byNum: Map}} index Role index maps.
- * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers.
+ * @param {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}} index Role index maps.
+ * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers.
* @param {Function} fetchImpl Fetch implementation.
+ * @param {string} [contextLabel='node metadata'] Context string used in warning logs.
* @returns {Promise} Completion promise.
*/
-async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) {
+async function fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl, contextLabel = 'node metadata') {
if (!(fetchIdMap instanceof Map) || fetchIdMap.size === 0) {
return;
}
@@ -1447,7 +1464,7 @@ async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) {
return;
}
const tasks = [];
- for (const [normalized, raw] of fetchIdMap.entries()) {
+ for (const [, raw] of fetchIdMap.entries()) {
const task = (async () => {
try {
const response = await fetchFn(`/api/nodes/${encodeURIComponent(raw)}`, DEFAULT_FETCH_OPTIONS);
@@ -1470,7 +1487,7 @@ async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) {
longName: payload?.long_name ?? payload?.longName ?? null,
});
} catch (error) {
- console.warn('Failed to resolve neighbor role', error);
+ console.warn(`Failed to resolve ${contextLabel}`, error);
}
})();
tasks.push(task);
@@ -1486,6 +1503,18 @@ async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) {
}
}
+/**
+ * Fetch missing neighbor role assignments using the nodes API.
+ *
+ * @param {{byId: Map, byNum: Map}} index Role index maps.
+ * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers.
+ * @param {Function} fetchImpl Fetch implementation.
+ * @returns {Promise} Completion promise.
+ */
+async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) {
+ await fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl, 'neighbor role');
+}
+
/**
* Build an index of neighbor roles using cached data and API lookups.
*
@@ -2041,6 +2070,56 @@ function extractTracePath(trace) {
return path;
}
+/**
+ * Build a fetch map for trace nodes missing display metadata.
+ *
+ * @param {Array