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} traces Trace payloads to inspect. + * @param {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}} roleIndex Existing role index hydrated with known nodes. + * @returns {Map} Mapping of normalized identifiers to fetch payloads. + */ +function collectTraceNodeFetchMap(traces, roleIndex) { + const fetchIdMap = new Map(); + if (!Array.isArray(traces)) return fetchIdMap; + + for (const trace of traces) { + const path = extractTracePath(trace); + for (const ref of path) { + const identifier = ref?.identifier ?? null; + const numericId = ref?.numericId ?? null; + registerRoleCandidate(roleIndex, { identifier, numericId }); + const details = lookupNeighborDetails(roleIndex, { identifier, numericId }); + const hasNames = Boolean(stringOrNull(details?.shortName) || stringOrNull(details?.longName)); + if (hasNames) continue; + const normalized = normalizeNodeId(identifier); + const numericKey = numberOrNull(numericId); + const mapKey = normalized ?? (numericKey != null ? `#${numericKey}` : null); + const fetchKey = identifier ?? numericKey; + if (mapKey && fetchKey != null && !fetchIdMap.has(mapKey)) { + fetchIdMap.set(mapKey, fetchKey); + } + } + } + + return fetchIdMap; +} + +/** + * Build a role index enriched with node metadata for trace hops. + * + * @param {Array} traces Trace payloads. + * @param {{byId?: Map, byNum?: Map, detailsById?: Map, detailsByNum?: Map}} [baseIndex] + * Optional base role index to clone. + * @param {{ fetchImpl?: Function }} [options] Fetch overrides. + * @returns {Promise<{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}>} + * Hydrated role index containing hop metadata. + */ +async function buildTraceRoleIndex(traces, baseIndex = null, { fetchImpl } = {}) { + const roleIndex = cloneRoleIndex(baseIndex); + const fetchIdMap = collectTraceNodeFetchMap(traces, roleIndex); + await fetchNodeDetailsIntoIndex(roleIndex, fetchIdMap, fetchImpl, 'trace node metadata'); + return roleIndex; +} + /** * Render a trace path using short-name badges. * @@ -2138,7 +2217,7 @@ function renderNodeDetailHtml(node, { messages = [], traces = [], renderShortHtml, - neighborRoleIndex = null, + roleIndex = null, chartNowMs = Date.now(), } = {}) { const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, { @@ -2153,8 +2232,8 @@ function renderNodeDetailHtml(node, { const identifier = stringOrNull(node.nodeId ?? node.node_id); const tableHtml = renderSingleNodeTable(node, renderShortHtml); const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs }); - const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex: neighborRoleIndex }); - const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex: neighborRoleIndex, node }); + const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex }); + const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node }); const messagesHtml = renderMessages(messages, renderShortHtml, node); const sections = []; @@ -2329,12 +2408,13 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) { }), fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }), ]); + const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl }); return renderNodeDetailHtml(node, { neighbors: node.neighbors, messages, traces, renderShortHtml, - neighborRoleIndex, + roleIndex, }); } @@ -2406,11 +2486,14 @@ export const __testUtils = { formatSnr, padTwo, normalizeNodeId, + cloneRoleIndex, registerRoleCandidate, lookupRole, lookupNeighborDetails, seedNeighborRoleIndex, buildNeighborRoleIndex, + collectTraceNodeFetchMap, + buildTraceRoleIndex, categoriseNeighbors, renderNeighborGroups, renderSingleNodeTable,