update node detail hydration for traces (#490)

* update node detail hydration for traces

* cover missing unit test vectors
This commit is contained in:
l5y
2025-11-21 19:20:46 +01:00
committed by GitHub
parent f93c14a9c5
commit 134cf92c6d
2 changed files with 205 additions and 10 deletions

View File

@@ -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 => `<span class="short-name">${short}</span>`,
});
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 () => ({}) }),

View File

@@ -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<string, string>, byNum: Map<number, string>, detailsById: Map<string, Object>, detailsByNum: Map<number, Object>}}
* 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<string, string>, byNum: Map<number, string>}} index Role index maps.
* @param {Map<string, string>} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers.
* @param {{byId: Map<string, string>, byNum: Map<number, string>, detailsById: Map<string, Object>, detailsByNum: Map<number, Object>}} index Role index maps.
* @param {Map<string, *>} 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<void>} 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<string, string>, byNum: Map<number, string>}} index Role index maps.
* @param {Map<string, string>} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers.
* @param {Function} fetchImpl Fetch implementation.
* @returns {Promise<void>} 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<Object>} traces Trace payloads to inspect.
* @param {{byId: Map<string, string>, byNum: Map<number, string>, detailsById: Map<string, Object>, detailsByNum: Map<number, Object>}} roleIndex Existing role index hydrated with known nodes.
* @returns {Map<string, *>} 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<Object>} traces Trace payloads.
* @param {{byId?: Map<string, string>, byNum?: Map<number, string>, detailsById?: Map<string, Object>, detailsByNum?: Map<number, Object>}} [baseIndex]
* Optional base role index to clone.
* @param {{ fetchImpl?: Function }} [options] Fetch overrides.
* @returns {Promise<{byId: Map<string, string>, byNum: Map<number, string>, detailsById: Map<string, Object>, detailsByNum: Map<number, Object>}>}
* 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,