mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
update node detail hydration for traces (#490)
* update node detail hydration for traces * cover missing unit test vectors
This commit is contained in:
@@ -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 () => ({}) }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user