web: refactor 6/7 node page (#777)

* web: refactor 6/7 node page

* web: address node-page refactor review and close coverage gaps

Fix the concurrency cap in fetchNodeDetailsIntoIndex so it actually
limits in-flight requests.  The previous implementation built each
fetch as an immediately-invoked async IIFE, so all N fetches started
the moment the loop ran; the slicing-then-Promise.all step only
changed when settlement was observed, not when work began.  Replace
the IIFE-then-batch pattern with a worker pool: a fixed-size set of
worker promises iterates a shared queue and only pulls the next
identifier once the previous fetch settles.

Reduce cross-module coupling around the role-aware short-name badge
by extracting renderRoleAwareBadge into a new badge.js module that
single-node-table, messages, detail-html, and traces import directly,
so the neighbour module is no longer pulled in by four non-neighbour
callers.  Tighten applyDetails in role-index.js by hoisting the
ternary into a single key binding and dropping the redundant
instanceof Map guard.

Close the patch-coverage gap reported by Codecov: add tests for
parameter-validation paths in bootstrap (parseReferencePayload,
normalizeNodeReference, fetchNodeDetailHtml, initializeNodeDetailPage),
the worker-pool branches in role-index (no-fetch, empty queue, 404,
non-success responses, and an explicit concurrency-cap assertion),
the badge fallback path, the nested-neighbor seedNeighborRoleIndex
branches, the renderNeighborBadge metadata-merge and short-name
fallback paths, the empty-trace and empty-chart short-circuits, and
single-node-table validation.  All ten node-page submodules now
report 100% line coverage.
This commit is contained in:
l5y
2026-05-02 23:05:36 +02:00
committed by GitHub
parent 1041e06644
commit 7b38f92b2d
11 changed files with 2028 additions and 1474 deletions
@@ -42,9 +42,13 @@ const {
lookupNeighborDetails,
seedNeighborRoleIndex,
buildNeighborRoleIndex,
fetchNodeDetailsIntoIndex,
renderRoleAwareBadge,
collectTraceNodeFetchMap,
buildTraceRoleIndex,
categoriseNeighbors,
renderNeighborBadge,
renderNeighborGroup,
renderNeighborGroups,
renderSingleNodeTable,
classifySnapshot,
@@ -1257,3 +1261,245 @@ test('initializeNodeDetailPage handles missing reference payloads', async () =>
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Node reference unavailable'), true);
});
test('parseReferencePayload returns null for blank or unparseable input', () => {
assert.equal(parseReferencePayload(null), null);
assert.equal(parseReferencePayload(' '), null);
assert.equal(parseReferencePayload('not-json'), null);
assert.equal(parseReferencePayload(JSON.stringify(42)), null);
assert.deepEqual(parseReferencePayload(JSON.stringify({ nodeId: '!a' })), { nodeId: '!a' });
});
test('initializeNodeDetailPage rejects invalid documents and missing identifiers', async () => {
await assert.rejects(
() => initializeNodeDetailPage({ document: null, fetchImpl: async () => ({}) }),
/document with querySelector/,
);
const root = { dataset: { nodeReference: JSON.stringify({}) }, innerHTML: '' };
const documentStub = {
querySelector: selector => (selector === '#nodeDetail' ? root : null),
};
const result = await initializeNodeDetailPage({
document: documentStub,
fetchImpl: async () => ({ ok: true, json: async () => ({}) }),
renderShortHtml: short => `<span>${short}</span>`,
});
assert.equal(result, false);
assert.equal(root.innerHTML.includes('Node identifier missing'), true);
});
test('renderRoleAwareBadge falls back when both shortName and identifier are absent', () => {
const html = renderRoleAwareBadge((short, role) => `<b data-role="${role}">${short}</b>`, {});
assert.equal(html, '<b data-role="CLIENT">?</b>');
});
test('renderRoleAwareBadge invokes default span renderer when renderShortHtml is missing', () => {
const html = renderRoleAwareBadge(null, { shortName: 'AB&CD' });
assert.equal(html.includes('class="short-name"'), true);
assert.equal(html.includes('AB&amp;CD'), true);
});
test('seedNeighborRoleIndex tolerates non-array and non-object entries', () => {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
assert.equal(seedNeighborRoleIndex(index, null).size, 0);
assert.equal(seedNeighborRoleIndex(index, 'not-an-array').size, 0);
assert.equal(seedNeighborRoleIndex(index, [null, 7, 'string']).size, 0);
});
test('seedNeighborRoleIndex hydrates roles from nested neighbor and node objects', () => {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
seedNeighborRoleIndex(index, [
{
neighbor: { node_id: '!ally', node_num: 11, role: 'ROUTER', short_name: 'ALLY', long_name: 'Ally Long' },
node: { node_id: '!self', node_num: 22, role: 'CLIENT', short_name: 'SELF', long_name: 'Self Long' },
},
]);
assert.equal(index.byId.get('!ally'), 'ROUTER');
assert.equal(index.byId.get('!self'), 'CLIENT');
assert.equal(index.byNum.get(11), 'ROUTER');
assert.equal(index.byNum.get(22), 'CLIENT');
const allyDetails = lookupNeighborDetails(index, { identifier: '!ally' });
assert.equal(allyDetails.shortName, 'ALLY');
assert.equal(allyDetails.longName, 'Ally Long');
});
test('fetchNodeDetailsIntoIndex skips work when no fetch is reachable', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = undefined;
try {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
await fetchNodeDetailsIntoIndex(index, new Map([['x', 'x']]), undefined);
assert.equal(index.byId.size, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test('fetchNodeDetailsIntoIndex returns immediately for empty or non-Map inputs', async () => {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
let calls = 0;
const fetchImpl = async () => { calls += 1; return { ok: true, json: async () => ({}) }; };
await fetchNodeDetailsIntoIndex(index, null, fetchImpl);
await fetchNodeDetailsIntoIndex(index, new Map(), fetchImpl);
assert.equal(calls, 0);
});
test('fetchNodeDetailsIntoIndex silently ignores 404 responses without registering', async () => {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
const fetchImpl = async () => ({ ok: false, status: 404, json: async () => ({}) });
await fetchNodeDetailsIntoIndex(index, new Map([['gone', 'gone']]), fetchImpl);
assert.equal(index.byId.size, 0);
});
test('renderSingleNodeTable returns empty string for invalid inputs', () => {
assert.equal(renderSingleNodeTable(null, () => ''), '');
assert.equal(renderSingleNodeTable({ nodeId: '!a' }, null), '');
assert.equal(renderSingleNodeTable('string-not-object', () => ''), '');
});
test('renderTelemetryCharts returns empty string when no entries fall in the window', () => {
const out = renderTelemetryCharts(makeAggregatedNode([
{ timestamp: '1970-01-01T00:00:00Z' },
]), { now: () => Date.UTC(2026, 0, 1) });
assert.equal(out, '');
});
test('renderTelemetryCharts returns empty string when chart specs produce no markup', () => {
// Aggregated snapshot with valid timestamp but no telemetry fields any chart
// can plot — every chart spec filters its empty series and returns ''.
const node = makeAggregatedNode([
{ rx_time: CHART_NOW_SECONDS - 60, telemetry_type: 'device' },
]);
const out = renderTelemetryCharts(node, { nowMs: CHART_NOW_MS });
assert.equal(out, '');
});
test('renderTracePath returns empty string when fewer than two badges render', () => {
const renderShortHtml = short => `<span>${short}</span>`;
// Single-element path → items.length < 2 → empty result.
assert.equal(renderTracePath(['!only'], renderShortHtml), '');
// Two refs but the renderer yields blanks → filter strips them → items.length < 2.
assert.equal(renderTracePath([{ identifier: '!a' }, { identifier: '!b' }], () => ''), '');
});
test('renderNeighborBadge returns empty string for invalid inputs', () => {
assert.equal(renderNeighborBadge(null, 'heardBy', () => ''), '');
assert.equal(renderNeighborBadge({ neighbor_id: '!a' }, 'weHear', null), '');
// Entry without any identifier in keys → returns ''
assert.equal(renderNeighborBadge({ snr: 5 }, 'weHear', () => ''), '');
});
test('renderNeighborBadge merges role-index metadata into the source object', () => {
const source = {};
const entry = { neighbor_id: '!ally', neighbor: source };
const roleIndex = {
byId: new Map([['!ally', 'ROUTER']]),
byNum: new Map(),
detailsById: new Map([
['!ally', { shortName: 'ALLY', longName: 'Ally Long', role: 'ROUTER' }],
]),
detailsByNum: new Map(),
};
const renderShortHtml = (short, role, long, badgeSource) =>
`<b data-role="${role}" data-long="${long}" data-source-role="${badgeSource.role}">${short}</b>`;
const html = renderNeighborBadge(entry, 'weHear', renderShortHtml, roleIndex);
assert.match(html, /ALLY/);
assert.equal(source.short_name, 'ALLY');
assert.equal(source.long_name, 'Ally Long');
assert.equal(source.role, 'ROUTER');
});
test('renderNeighborBadge derives short name from identifier when no metadata is available', () => {
const html = renderNeighborBadge(
{ neighbor_id: '!abcdef12' },
'weHear',
short => `<span>${short}</span>`,
);
// Last four hex chars of identifier, uppercased.
assert.equal(html, '<span>EF12</span>');
});
test('renderNeighborGroup skips entries that fail to render and returns empty when none survive', () => {
const renderShortHtml = (short, role) => `<span data-role="${role}">${short}</span>`;
// Two entries; only one yields a valid badge.
const html = renderNeighborGroup(
'Heard by',
[
{ node_id: '!peer', node_short_name: 'PEER' },
{ snr: 5 }, // no identifier → renderNeighborBadge returns '' → filtered out.
],
'heardBy',
renderShortHtml,
);
assert.equal(html.includes('PEER'), true);
assert.equal(html.match(/<li>/g).length, 1);
// All entries fail → returns ''.
const empty = renderNeighborGroup('Heard by', [{ snr: 1 }, { snr: 2 }], 'heardBy', renderShortHtml);
assert.equal(empty, '');
});
test('renderTraceroutes returns empty string when no trace path renders content', () => {
const renderShortHtml = short => `<span>${short}</span>`;
// Each trace yields a single-hop path which renderTracePath rejects → no items remain.
assert.equal(renderTraceroutes([{ src: '!a', hops: [], dest: null }], renderShortHtml), '');
});
test('fetchNodeDetailHtml rejects non-object references', async () => {
await assert.rejects(() => fetchNodeDetailHtml(null), TypeError);
await assert.rejects(() => fetchNodeDetailHtml('not-an-object'), TypeError);
});
test('normalizeNodeReference returns null for non-object inputs and references missing both ids', () => {
const { normalizeNodeReference } = __testUtils;
assert.equal(normalizeNodeReference(null), null);
assert.equal(normalizeNodeReference('not-an-object'), null);
assert.equal(normalizeNodeReference({}), null);
assert.deepEqual(normalizeNodeReference({ nodeId: '!a' }), { nodeId: '!a', nodeNum: null });
});
test('fetchNodeDetailsIntoIndex warns and continues when a non-404 response fails', async () => {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
const fetchImpl = async () => ({ ok: false, status: 503, json: async () => ({}) });
const originalWarn = console.warn;
const messages = [];
console.warn = (...args) => messages.push(args[0]);
try {
await fetchNodeDetailsIntoIndex(index, new Map([['ouch', 'ouch']]), fetchImpl, 'unit-test');
} finally {
console.warn = originalWarn;
}
assert.equal(index.byId.size, 0);
assert.equal(messages.some(msg => typeof msg === 'string' && msg.includes('unit-test')), true);
});
test('fetchNodeDetailsIntoIndex caps in-flight requests at NEIGHBOR_ROLE_FETCH_CONCURRENCY', async () => {
// Eight identifiers, four-wide pool: at most four fetches should be in flight.
const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const fetchIdMap = new Map(ids.map(id => [id, id]));
let inFlight = 0;
let peak = 0;
const release = [];
const fetchImpl = () => {
inFlight += 1;
if (inFlight > peak) peak = inFlight;
return new Promise(resolve => {
release.push(() => {
inFlight -= 1;
resolve({ ok: true, status: 200, json: async () => ({ node_id: '!stub', role: 'CLIENT' }) });
});
});
};
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
const work = fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl);
// Yield so the four workers reach their first await.
await new Promise(resolve => setImmediate(resolve));
assert.equal(peak, 4, `expected concurrency cap of 4, observed peak ${peak}`);
// Drain in two waves; the second wave only starts once the first releases.
release.splice(0, 4).forEach(fn => fn());
await new Promise(resolve => setImmediate(resolve));
release.splice(0).forEach(fn => fn());
await work;
assert.equal(peak, 4);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Shared role-aware short-name badge renderer used across node-page submodules.
*
* @module node-page/badge
*/
import { escapeHtml } from '../utils.js';
import { numberOrNull, stringOrNull } from '../value-helpers.js';
/**
* Render a short-name badge with consistent role-aware styling.
*
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {{
* shortName?: string|null,
* longName?: string|null,
* role?: string|null,
* identifier?: string|null,
* numericId?: number|null,
* source?: Object|null,
* }} payload Badge rendering payload.
* @returns {string} HTML snippet describing the badge.
*/
export function renderRoleAwareBadge(renderShortHtml, {
shortName = null,
longName = null,
role = null,
identifier = null,
numericId = null,
source = null,
} = {}) {
const resolvedIdentifier = stringOrNull(identifier);
const resolvedShort = stringOrNull(shortName);
const resolvedLong = stringOrNull(longName);
const resolvedRole = stringOrNull(role) ?? 'CLIENT';
const resolvedNumericId = numberOrNull(numericId);
let fallbackShort = resolvedShort;
if (!fallbackShort && resolvedIdentifier) {
const trimmed = resolvedIdentifier.replace(/^!+/, '');
fallbackShort = trimmed.slice(-4).toUpperCase();
}
if (!fallbackShort) {
fallbackShort = '?';
}
const badgeSource = source && typeof source === 'object' ? { ...source } : {};
if (resolvedIdentifier) {
if (!badgeSource.node_id) badgeSource.node_id = resolvedIdentifier;
if (!badgeSource.nodeId) badgeSource.nodeId = resolvedIdentifier;
}
if (resolvedNumericId != null) {
if (!badgeSource.node_num) badgeSource.node_num = resolvedNumericId;
if (!badgeSource.nodeNum) badgeSource.nodeNum = resolvedNumericId;
}
if (resolvedShort) {
if (!badgeSource.short_name) badgeSource.short_name = resolvedShort;
if (!badgeSource.shortName) badgeSource.shortName = resolvedShort;
}
if (resolvedLong) {
if (!badgeSource.long_name) badgeSource.long_name = resolvedLong;
if (!badgeSource.longName) badgeSource.longName = resolvedLong;
}
badgeSource.role = badgeSource.role ?? resolvedRole;
if (typeof renderShortHtml === 'function') {
return renderShortHtml(resolvedShort ?? fallbackShort, resolvedRole, resolvedLong, badgeSource);
}
return `<span class="short-name">${escapeHtml(resolvedShort ?? fallbackShort)}</span>`;
}
+208
View File
@@ -0,0 +1,208 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Page-bootstrap helpers — DOM hydration and orchestration of the data fetch.
*
* @module node-page/bootstrap
*/
import { escapeHtml } from '../utils.js';
import { refreshNodeInformation } from '../node-details.js';
import { fetchMessages, fetchNodesById, fetchTracesForNode } from '../node-page-data.js';
import { numberOrNull, stringOrNull } from '../value-helpers.js';
import { buildNeighborRoleIndex } from './role-index.js';
import { buildTraceRoleIndex } from './traces.js';
import { renderNodeDetailHtml } from './detail-html.js';
const RENDER_WAIT_INTERVAL_MS = 20;
const RENDER_WAIT_TIMEOUT_MS = 500;
/**
* Parse the serialized reference payload embedded in the DOM.
*
* @param {string} raw Raw JSON string.
* @returns {Object|null} Parsed object or ``null`` when invalid.
*/
export function parseReferencePayload(raw) {
const trimmed = stringOrNull(raw);
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
console.warn('Failed to parse node reference payload', error);
return null;
}
}
/**
* Normalise a node reference payload by extracting the canonical identifier or number.
*
* @param {*} reference Candidate reference object.
* @returns {{nodeId: (string|null), nodeNum: (number|null)}|null} Normalised reference.
*/
export function normalizeNodeReference(reference) {
if (!reference || typeof reference !== 'object') {
return null;
}
const nodeId = stringOrNull(reference.nodeId ?? reference.node_id);
const nodeNum = numberOrNull(reference.nodeNum ?? reference.node_num ?? reference.num);
if (!nodeId && nodeNum == null) {
return null;
}
return { nodeId, nodeNum };
}
/**
* Resolve the canonical renderShortHtml implementation, waiting briefly for
* the dashboard to expose it when necessary.
*
* @param {Function|undefined} override Explicit override supplied by tests.
* @returns {Promise<Function>} Badge rendering implementation.
*/
export async function resolveRenderShortHtml(override) {
if (typeof override === 'function') return override;
const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
const candidate = globalThis.PotatoMesh?.renderShortHtml;
if (typeof candidate === 'function') {
return candidate;
}
await new Promise(resolve => setTimeout(resolve, RENDER_WAIT_INTERVAL_MS));
}
return short => `<span class="short-name">${escapeHtml(short ?? '?')}</span>`;
}
/**
* Fetch node detail HTML for the supplied reference payload.
*
* @param {Object} referenceData Node reference object embedded in the DOM.
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* privateMode?: boolean,
* }} [options] Optional overrides for testing.
* @returns {Promise<string>} HTML fragment for the detail view.
*/
export async function fetchNodeDetailHtml(referenceData, options = {}) {
if (!referenceData || typeof referenceData !== 'object') {
throw new TypeError('A node reference object is required to render node details');
}
const normalized = normalizeNodeReference(referenceData);
if (!normalized) {
throw new Error('Node identifier missing.');
}
const refreshImpl = typeof options.refreshImpl === 'function' ? options.refreshImpl : refreshNodeInformation;
const renderShortHtml = await resolveRenderShortHtml(options.renderShortHtml);
const node = await refreshImpl(referenceData, { fetchImpl: options.fetchImpl });
const neighborRoleIndex = await buildNeighborRoleIndex(node, node.neighbors, {
fetchImpl: options.fetchImpl,
});
const messageIdentifier =
normalized.nodeId ??
stringOrNull(node.nodeId ?? node.node_id) ??
(normalized.nodeNum != null ? normalized.nodeNum : null);
// Fetch messages, traces, and the global node registry in parallel. The
// registry is used by the chat-entry renderer to resolve MeshCore
// ``@[Name]`` mentions and reply targets that reference nodes other than
// the page's own node — without it, mention badges silently degrade to
// plain ``@[Name]`` text and leading-mention replies don't surface as
// ``[in reply to ...]`` prefixes.
const [messages, traces, nodesById] = await Promise.all([
fetchMessages(messageIdentifier, {
fetchImpl: options.fetchImpl,
privateMode: options.privateMode === true,
}),
fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }),
fetchNodesById({ fetchImpl: options.fetchImpl }),
]);
const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl });
return renderNodeDetailHtml(node, {
neighbors: node.neighbors,
messages,
traces,
renderShortHtml,
roleIndex,
nodesById,
});
}
/**
* Initialise the node detail page by hydrating the DOM with fetched data.
*
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* }} [options] Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
*/
export async function initializeNodeDetailPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.querySelector !== 'function') {
throw new TypeError('A document with querySelector support is required');
}
const root = documentRef.querySelector('#nodeDetail');
if (!root) return false;
const filterContainer = typeof documentRef.querySelector === 'function'
? documentRef.querySelector('.filter-input')
: null;
if (filterContainer) {
if (typeof filterContainer.remove === 'function') {
filterContainer.remove();
} else {
filterContainer.hidden = true;
}
}
const referenceData = parseReferencePayload(root.dataset?.nodeReference ?? null);
if (!referenceData) {
root.innerHTML = '<p class="node-detail__error">Node reference unavailable.</p>';
return false;
}
const identifier = stringOrNull(referenceData.nodeId) ?? null;
const nodeNum = numberOrNull(referenceData.nodeNum);
if (!identifier && nodeNum == null) {
root.innerHTML = '<p class="node-detail__error">Node identifier missing.</p>';
return false;
}
const refreshImpl = typeof options.refreshImpl === 'function' ? options.refreshImpl : refreshNodeInformation;
const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true';
try {
const html = await fetchNodeDetailHtml(referenceData, {
fetchImpl: options.fetchImpl,
refreshImpl,
renderShortHtml: options.renderShortHtml,
privateMode,
});
root.innerHTML = html;
return true;
} catch (error) {
console.error('Failed to render node detail page', error);
root.innerHTML = '<p class="node-detail__error">Failed to load node details.</p>';
return false;
}
}
@@ -0,0 +1,100 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Top-level layout assembly for the node detail page.
*
* @module node-page/detail-html
*/
import { escapeHtml } from '../utils.js';
import { protocolIconPrefixHtml } from '../protocol-helpers.js';
import { stringOrNull } from '../value-helpers.js';
import { renderRoleAwareBadge } from './badge.js';
import { renderNeighborGroups } from './neighbor-rendering.js';
import { renderSingleNodeTable } from './single-node-table.js';
import { renderTelemetryCharts } from './telemetry-charts.js';
import { renderMessages } from './messages.js';
import { renderTraceroutes } from './traces.js';
/**
* Render the node detail layout to an HTML fragment.
*
* @param {Object} node Normalised node payload.
* @param {{
* neighbors?: Array<Object>,
* messages?: Array<Object>,
* traces?: Array<Object>,
* renderShortHtml: Function,
* roleIndex?: Object|null,
* chartNowMs?: number,
* nodesById?: Map<string, Object>|null,
* }} options Rendering options.
* @returns {string} HTML fragment representing the detail view.
*/
export function renderNodeDetailHtml(node, {
neighbors = [],
messages = [],
traces = [],
renderShortHtml,
roleIndex = null,
chartNowMs = Date.now(),
nodesById = null,
} = {}) {
const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, {
shortName: node.shortName ?? node.short_name,
longName: node.longName ?? node.long_name,
role: node.role,
identifier: node.nodeId ?? node.node_id ?? null,
numericId: node.nodeNum ?? node.node_num ?? node.num ?? null,
source: node.rawSources?.node ?? node,
});
const longName = stringOrNull(node.longName ?? node.long_name);
const identifier = stringOrNull(node.nodeId ?? node.node_id);
const nodeProtocol = stringOrNull(node.protocol) ?? null;
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
const messagesHtml = renderMessages(messages, renderShortHtml, node, nodesById);
const sections = [];
if (neighborsHtml) {
sections.push(neighborsHtml);
}
if (tracesHtml) {
sections.push(tracesHtml);
}
if (Array.isArray(messages) && messages.length > 0 && messagesHtml) {
sections.push(`<section class="node-detail__section"><h3>Messages</h3>${messagesHtml}</section>`);
}
const identifierHtml = identifier ? `<span class="node-detail__identifier">[${escapeHtml(identifier)}]</span>` : '';
const iconPrefix = protocolIconPrefixHtml(nodeProtocol);
const nameHtml = longName ? `<span class="node-detail__name">${iconPrefix}${escapeHtml(longName)}</span>` : '';
const badgeHtml = `<span class="node-detail__badge">${roleAwareBadge}</span>`;
const tableSection = tableHtml ? `<div class="node-detail__table">${tableHtml}</div>` : '';
const contentHtml = sections.length > 0 ? `<div class="node-detail__content">${sections.join('')}</div>` : '';
return `
<header class="node-detail__header">
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
</header>
${chartsHtml ?? ''}
${tableSection}
${contentHtml}
`;
}
@@ -0,0 +1,227 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Render the chat-style message log for a node detail page.
*
* @module node-page/messages
*/
import { escapeHtml } from '../utils.js';
import { protocolIconPrefixHtml } from '../protocol-helpers.js';
import {
extractChatMessageMetadata,
formatChatChannelTag,
formatChatMessagePrefix,
formatChatPresetTag,
} from '../chat-format.js';
import { buildMessageIndex } from '../message-replies.js';
import { renderChatEntryContent } from '../chat-entry-renderer.js';
import { formatMessageTimestamp } from '../node-page-charts.js';
import { numberOrNull, stringOrNull } from '../value-helpers.js';
import { renderRoleAwareBadge } from './badge.js';
/**
* Render the emoji HTML fragment used by the chat entry renderer.
*
* Matches the markup emitted by the dashboard chat panel so that both the
* node detail page and the dashboard use identical emoji styling.
*
* @param {string} symbol Emoji character.
* @returns {string} HTML fragment wrapping the emoji in a chat-entry span.
*/
export function renderNodeChatEmojiHtml(symbol) {
const trimmed = String(symbol ?? '').trim();
if (!trimmed) return '';
return `<span class="chat-entry-emoji" aria-hidden="true">${escapeHtml(trimmed)}</span>`;
}
/**
* Build a ``nodesById`` Map for chat-entry rendering.
*
* Layered sources, in priority order:
*
* 1. The global node registry fetched via ``fetchNodesById`` (the same
* data the dashboard uses). This is what allows MeshCore mentions
* like ``@[Some Other Node]`` to resolve to a badge instead of
* degrading to plain ``@[Name]`` text on the node detail page.
* 2. Hydrated ``message.node`` objects on the loaded messages — used as
* a fallback for senders that may not be in the global registry.
* 3. The current page's own node, ensuring self-references and the
* sender badge resolve even when neither of the above contains it.
*
* Known tradeoff: reply targets whose source message lies outside the
* loaded message window will not resolve via {@link resolveReplyPrefix},
* so the rendered ``[in reply to ...]`` prefix silently degrades to the
* empty string for those rows.
*
* @param {Array<Object>} messages Message records.
* @param {?Object} fallbackNode Current page node.
* @param {?Map<string, Object>} globalNodesById Global node registry.
* @returns {Map<string, Object>} Lookup map keyed by node identifier.
*/
export function buildNodesById(messages, fallbackNode, globalNodesById) {
const map = new Map();
if (globalNodesById instanceof Map) {
for (const [id, node] of globalNodesById.entries()) {
if (id && node && typeof node === 'object') map.set(id, node);
}
}
const register = (node) => {
if (!node || typeof node !== 'object') return;
const id = node.node_id ?? node.nodeId ?? null;
if (!id) return;
if (!map.has(id)) map.set(id, node);
};
if (Array.isArray(messages)) {
for (const message of messages) {
if (!message || typeof message !== 'object') continue;
register(message.node);
}
}
register(fallbackNode);
return map;
}
/**
* Render a message list using structured metadata formatting.
*
* Each entry is rendered through the shared {@link renderChatEntryContent}
* helper so the node detail page mirrors the dashboard chat panel —
* mentions resolve to badges, MeshCore leading-mention replies surface as
* an ``[in reply to]`` prefix, emoji are wrapped in ``chat-entry-emoji``
* spans, and URLs are linkified.
*
* @param {Array<Object>} messages Message records.
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {Object} node Node context used when message metadata is incomplete.
* @param {?Map<string, Object>} [globalNodesById] Optional global node
* registry used for mention/reply resolution; falls back to message-derived
* sender nodes when omitted.
* @returns {string} HTML string for the messages section.
*/
export function renderMessages(messages, renderShortHtml, node, globalNodesById = null) {
if (!Array.isArray(messages) || messages.length === 0) return '';
const fallbackNode = node && typeof node === 'object' ? node : null;
const nodesById = buildNodesById(messages, fallbackNode, globalNodesById);
const messagesById = buildMessageIndex(messages);
const items = messages
.map(message => {
if (!message || typeof message !== 'object') return null;
const hasBody = stringOrNull(message.text) || stringOrNull(message.emoji);
if (!hasBody) return null;
// Filter out the canonical empty-payload encrypted marker. This keeps
// the original semantics of falling back to ``message.emoji`` when the
// text field is null (some encrypted packets carry the marker only in
// the emoji slot).
if (message.encrypted && String(hasBody).trim() === 'GAA=') {
return null;
}
const timestamp = formatMessageTimestamp(message.rx_time, message.rx_iso);
const metadata = extractChatMessageMetadata(message);
if (!metadata.channelName) {
const fallbackChannel = stringOrNull(
message.channel_name
?? message.channelName
?? message.channel_label
?? null,
);
if (fallbackChannel) {
metadata.channelName = fallbackChannel;
} else {
const numericChannel = numberOrNull(message.channel);
if (numericChannel != null) {
metadata.channelName = String(numericChannel);
} else if (stringOrNull(message.channel)) {
metadata.channelName = stringOrNull(message.channel);
}
}
}
const prefix = formatChatMessagePrefix({
timestamp: escapeHtml(timestamp ?? ''),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : null,
});
const presetTag = formatChatPresetTag({ presetCode: metadata.presetCode });
const channelTag = formatChatChannelTag({ channelName: metadata.channelName });
// Render the message body through the shared chat-entry renderer so
// the node page matches the dashboard in mention/reply/emoji handling.
const { html: bodyHtml, meshcoreSenderNode } = renderChatEntryContent({
message,
nodesById,
messagesById,
renderShortHtml,
escapeHtml,
renderEmojiHtml: renderNodeChatEmojiHtml,
});
// Resolve the sender badge. When the ingestor could not hydrate
// ``message.node`` for a MeshCore channel message, fall back to the
// node resolved by the shared renderer via the sender-prefix lookup.
const senderNode = message.node && typeof message.node === 'object'
? message.node
: (meshcoreSenderNode && typeof meshcoreSenderNode === 'object' ? meshcoreSenderNode : null);
const messageProtocol = stringOrNull(senderNode?.protocol ?? fallbackNode?.protocol) ?? null;
const protocolIconHtml = protocolIconPrefixHtml(messageProtocol);
const badgeHtml = renderRoleAwareBadge(renderShortHtml, {
shortName: senderNode?.short_name ?? senderNode?.shortName ?? fallbackNode?.shortName ?? fallbackNode?.short_name,
longName: senderNode?.long_name ?? senderNode?.longName ?? fallbackNode?.longName ?? fallbackNode?.long_name,
role: senderNode?.role ?? fallbackNode?.role ?? null,
identifier:
message.node_id
?? message.nodeId
?? message.from_id
?? message.fromId
?? senderNode?.node_id
?? senderNode?.nodeId
?? fallbackNode?.nodeId
?? fallbackNode?.node_id
?? null,
numericId:
message.node_num
?? message.nodeNum
?? message.from_num
?? message.fromNum
?? senderNode?.node_num
?? senderNode?.nodeNum
?? fallbackNode?.nodeNum
?? fallbackNode?.node_num
?? null,
source: senderNode ?? fallbackNode?.rawSources?.node ?? fallbackNode,
});
return `<div class="chat-entry-msg">${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml} ${bodyHtml}</div>`;
})
.filter(item => item != null);
if (items.length === 0) return '';
// Wrap entries in the same chat-panel chrome the dashboard and chat sub
// pages use so this section visually matches a real chat panel rather
// than a plain bullet list. ``chat-tabpanel`` provides the scrollable
// padded inner container; ``chat-panel--node-detail`` is a hook for any
// node-page-specific overrides (height, border behaviour) that we want
// distinct from the dashboard's 60vh default.
return (
'<div class="chat-panel chat-panel--node-detail" aria-label="Chat log">'
+ '<div class="chat-tabpanel">'
+ items.join('')
+ '</div>'
+ '</div>'
);
}
@@ -0,0 +1,242 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Neighbour-section rendering and the shared role-aware short-name badge.
*
* @module node-page/neighbor-rendering
*/
import { escapeHtml } from '../utils.js';
import { formatSnr } from '../node-page-charts.js';
import { numberOrNull, stringOrNull } from '../value-helpers.js';
import { lookupNeighborDetails, lookupRole } from './role-index.js';
import { renderRoleAwareBadge } from './badge.js';
/**
* Determine whether a neighbour record references the current node.
*
* @param {Object} entry Raw neighbour entry.
* @param {string|null} ourId Canonical identifier for the current node.
* @param {number|null} ourNum Canonical numeric identifier for the current node.
* @param {Array<string>} idKeys Candidate identifier property names.
* @param {Array<string>} numKeys Candidate numeric identifier property names.
* @returns {boolean} ``true`` when the neighbour refers to the current node.
*/
export function neighborMatches(entry, ourId, ourNum, idKeys, numKeys) {
if (!entry || typeof entry !== 'object') return false;
const ids = idKeys
.map(key => stringOrNull(entry[key]))
.filter(candidate => candidate != null)
.map(candidate => candidate.toLowerCase());
if (ourId && ids.includes(ourId.toLowerCase())) {
return true;
}
if (ourNum == null) return false;
return numKeys
.map(key => numberOrNull(entry[key]))
.some(candidate => candidate != null && candidate === ourNum);
}
/**
* Categorise neighbour entries by their relationship to the current node.
*
* @param {Object} node Normalised node payload.
* @param {Array<Object>} neighbors Raw neighbour entries.
* @returns {{heardBy: Array<Object>, weHear: Array<Object>}} Categorised neighbours.
*/
export function categoriseNeighbors(node, neighbors) {
const heardBy = [];
const weHear = [];
if (!Array.isArray(neighbors) || neighbors.length === 0) {
return { heardBy, weHear };
}
const ourId = stringOrNull(node?.nodeId ?? node?.node_id) ?? null;
const ourNum = numberOrNull(node?.nodeNum ?? node?.node_num ?? node?.num);
neighbors.forEach(entry => {
if (!entry || typeof entry !== 'object') {
return;
}
const matchesNeighbor = neighborMatches(entry, ourId, ourNum, ['neighbor_id', 'neighborId'], ['neighbor_num', 'neighborNum']);
const matchesNode = neighborMatches(entry, ourId, ourNum, ['node_id', 'nodeId'], ['node_num', 'nodeNum']);
if (matchesNeighbor) {
heardBy.push(entry);
}
if (matchesNode) {
weHear.push(entry);
}
});
return { heardBy, weHear };
}
/**
* Generate a badge HTML fragment for a neighbour entry.
*
* @param {Object} entry Raw neighbour entry.
* @param {'heardBy'|'weHear'} perspective Group perspective describing the relation.
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {Object|null} [roleIndex] Role index providing supplementary metadata.
* @returns {string} HTML snippet for the badge or an empty string.
*/
export function renderNeighborBadge(entry, perspective, renderShortHtml, roleIndex = null) {
if (!entry || typeof entry !== 'object' || typeof renderShortHtml !== 'function') {
return '';
}
const idKeys = perspective === 'heardBy'
? ['node_id', 'nodeId', 'id']
: ['neighbor_id', 'neighborId', 'id'];
const numKeys = perspective === 'heardBy'
? ['node_num', 'nodeNum']
: ['neighbor_num', 'neighborNum'];
const shortKeys = perspective === 'heardBy'
? ['node_short_name', 'nodeShortName', 'short_name', 'shortName']
: ['neighbor_short_name', 'neighborShortName', 'short_name', 'shortName'];
const longKeys = perspective === 'heardBy'
? ['node_long_name', 'nodeLongName', 'long_name', 'longName']
: ['neighbor_long_name', 'neighborLongName', 'long_name', 'longName'];
const roleKeys = perspective === 'heardBy'
? ['node_role', 'nodeRole', 'role']
: ['neighbor_role', 'neighborRole', 'role'];
const identifier = idKeys.map(key => stringOrNull(entry[key])).find(value => value != null);
if (!identifier) return '';
const numericId = numKeys.map(key => numberOrNull(entry[key])).find(value => value != null) ?? null;
let shortName = shortKeys.map(key => stringOrNull(entry[key])).find(value => value != null) ?? null;
let longName = longKeys.map(key => stringOrNull(entry[key])).find(value => value != null) ?? null;
let role = roleKeys.map(key => stringOrNull(entry[key])).find(value => value != null) ?? null;
const source = perspective === 'heardBy' ? entry.node : entry.neighbor;
const metadata = lookupNeighborDetails(roleIndex, { identifier, numericId });
if (metadata) {
if (!shortName && metadata.shortName) {
shortName = metadata.shortName;
}
if (!role && metadata.role) {
role = metadata.role;
}
if (!longName && metadata.longName) {
longName = metadata.longName;
}
if (metadata.shortName && source && typeof source === 'object') {
if (!source.short_name) source.short_name = metadata.shortName;
if (!source.shortName) source.shortName = metadata.shortName;
}
if (metadata.longName && source && typeof source === 'object') {
if (!source.long_name) source.long_name = metadata.longName;
if (!source.longName) source.longName = metadata.longName;
}
if (metadata.role && source && typeof source === 'object' && !source.role) {
source.role = metadata.role;
}
}
if (!shortName) {
const trimmed = identifier.replace(/^!+/, '');
shortName = trimmed.slice(-4).toUpperCase();
}
if (!role && source && typeof source === 'object') {
role = stringOrNull(
source.role
?? source.node_role
?? source.nodeRole
?? source.neighbor_role
?? source.neighborRole
?? source.roleName
?? null,
);
}
if (!role) {
const sourceId = source && typeof source === 'object'
? source.node_id ?? source.nodeId ?? source.id ?? null
: null;
const sourceNum = source && typeof source === 'object'
? source.node_num ?? source.nodeNum ?? source.num ?? null
: null;
role = lookupRole(roleIndex, {
identifier: identifier ?? sourceId,
numericId: numericId ?? sourceNum,
});
}
return renderRoleAwareBadge(renderShortHtml, {
shortName,
longName,
role: role ?? 'CLIENT',
identifier,
numericId,
source,
});
}
/**
* Render a neighbour group as a titled list.
*
* @param {string} title Section title for the group.
* @param {Array<Object>} entries Neighbour entries included in the group.
* @param {'heardBy'|'weHear'} perspective Group perspective.
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {Object|null} [roleIndex] Role index providing supplementary metadata.
* @returns {string} HTML markup or an empty string when no entries render.
*/
export function renderNeighborGroup(title, entries, perspective, renderShortHtml, roleIndex = null) {
if (!Array.isArray(entries) || entries.length === 0) {
return '';
}
const items = entries
.map(entry => {
const badgeHtml = renderNeighborBadge(entry, perspective, renderShortHtml, roleIndex);
if (!badgeHtml) {
return null;
}
const snrDisplay = formatSnr(entry?.snr);
const snrHtml = snrDisplay ? `<span class="node-detail__neighbor-snr">(${escapeHtml(snrDisplay)})</span>` : '';
return `<li>${badgeHtml}${snrHtml}</li>`;
})
.filter(item => item != null);
if (items.length === 0) return '';
return `
<div class="node-detail__neighbors-group">
<h4 class="node-detail__neighbors-title">${escapeHtml(title)}</h4>
<ul class="node-detail__neighbors-list">${items.join('')}</ul>
</div>
`;
}
/**
* Render neighbour information grouped by signal direction.
*
* @param {Object} node Normalised node payload.
* @param {Array<Object>} neighbors Raw neighbour entries.
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {{ roleIndex?: Object|null }} [options] Rendering helpers.
* @returns {string} HTML markup for the neighbour section.
*/
export function renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex = null } = {}) {
const { heardBy, weHear } = categoriseNeighbors(node, neighbors);
const heardByHtml = renderNeighborGroup('Heard by', heardBy, 'heardBy', renderShortHtml, roleIndex);
const weHearHtml = renderNeighborGroup('We hear', weHear, 'weHear', renderShortHtml, roleIndex);
const groups = [heardByHtml, weHearHtml].filter(section => stringOrNull(section));
if (groups.length === 0) {
return '';
}
return `
<section class="node-detail__section node-detail__neighbors">
<h3>Neighbors</h3>
<div class="node-detail__neighbors-grid">${groups.join('')}</div>
</section>
`;
}
@@ -0,0 +1,426 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Neighbor / trace role index helpers shared across the node-detail submodules.
*
* @module node-page/role-index
*/
import { numberOrNull, stringOrNull } from '../value-helpers.js';
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'default' });
/**
* Maximum number of in-flight ``/api/nodes`` fetches issued in parallel when
* resolving role information for neighbour badges. Kept small to avoid
* overwhelming the server with bursts of concurrent requests.
*/
const NEIGHBOR_ROLE_FETCH_CONCURRENCY = 4;
/**
* Normalise a node identifier for consistent lookups.
*
* @param {*} identifier Candidate identifier.
* @returns {string|null} Lower-case identifier or ``null`` when invalid.
*/
export function normalizeNodeId(identifier) {
const value = stringOrNull(identifier);
return value ? value.toLowerCase() : null;
}
/**
* Register a role candidate within the supplied index.
*
* @param {{
* byId: Map<string, string>,
* byNum: Map<number, string>,
* detailsById: Map<string, Object>,
* detailsByNum: Map<number, Object>,
* }} index Role index maps.
* @param {{
* identifier?: *,
* numericId?: *,
* role?: *,
* shortName?: *,
* longName?: *,
* }} payload Role candidate payload.
* @returns {void}
*/
export function registerRoleCandidate(
index,
{ identifier = null, numericId = null, role = null, shortName = null, longName = null } = {},
) {
if (!index || typeof index !== 'object') return;
if (!(index.byId instanceof Map)) index.byId = new Map();
if (!(index.byNum instanceof Map)) index.byNum = new Map();
if (!(index.detailsById instanceof Map)) index.detailsById = new Map();
if (!(index.detailsByNum instanceof Map)) index.detailsByNum = new Map();
const resolvedRole = stringOrNull(role);
const resolvedShort = stringOrNull(shortName);
const resolvedLong = stringOrNull(longName);
const idKey = normalizeNodeId(identifier);
const numKey = numberOrNull(numericId);
if (resolvedRole) {
if (idKey && !index.byId.has(idKey)) {
index.byId.set(idKey, resolvedRole);
}
if (numKey != null && !index.byNum.has(numKey)) {
index.byNum.set(numKey, resolvedRole);
}
}
const applyDetails = (existing, keyType) => {
const key = keyType === 'id' ? idKey : numKey;
const current = key != null ? existing.get(key) : null;
const merged = current && typeof current === 'object' ? { ...current } : {};
if (resolvedRole && !merged.role) merged.role = resolvedRole;
if (resolvedShort && !merged.shortName) merged.shortName = resolvedShort;
if (resolvedLong && !merged.longName) merged.longName = resolvedLong;
if (keyType === 'id' && idKey && merged.identifier == null) merged.identifier = idKey;
if (keyType === 'num' && numKey != null && merged.numericId == null) {
merged.numericId = numKey;
}
return merged;
};
if (idKey) {
const merged = applyDetails(index.detailsById, 'id');
if (Object.keys(merged).length > 0) {
index.detailsById.set(idKey, merged);
}
}
if (numKey != null) {
const merged = applyDetails(index.detailsByNum, 'num');
if (Object.keys(merged).length > 0) {
index.detailsByNum.set(numKey, merged);
}
}
}
/**
* 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.
*/
export 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.
*
* @param {{byId?: Map<string, string>, byNum?: Map<number, string>}|null} index Role lookup maps.
* @param {{ identifier?: *, numericId?: * }} payload Lookup payload.
* @returns {string|null} Resolved role string or ``null`` when unavailable.
*/
export function lookupRole(index, { identifier = null, numericId = null } = {}) {
if (!index || typeof index !== 'object') return null;
const idKey = normalizeNodeId(identifier);
if (idKey && index.byId instanceof Map && index.byId.has(idKey)) {
return index.byId.get(idKey) ?? null;
}
const numKey = numberOrNull(numericId);
if (numKey != null && index.byNum instanceof Map && index.byNum.has(numKey)) {
return index.byNum.get(numKey) ?? null;
}
return null;
}
/**
* Resolve neighbour metadata from the provided index.
*
* @param {{
* detailsById?: Map<string, Object>,
* detailsByNum?: Map<number, Object>,
* byId?: Map<string, string>,
* byNum?: Map<number, string>,
* }|null} index Role lookup maps.
* @param {{ identifier?: *, numericId?: * }} payload Lookup payload.
* @returns {{ role?: string|null, shortName?: string|null, longName?: string|null }|null}
* Resolved metadata object or ``null`` when unavailable.
*/
export function lookupNeighborDetails(index, { identifier = null, numericId = null } = {}) {
if (!index || typeof index !== 'object') return null;
const idKey = normalizeNodeId(identifier);
const numKey = numberOrNull(numericId);
const details = {};
if (idKey && index.detailsById instanceof Map && index.detailsById.has(idKey)) {
Object.assign(details, index.detailsById.get(idKey));
}
if (numKey != null && index.detailsByNum instanceof Map && index.detailsByNum.has(numKey)) {
Object.assign(details, index.detailsByNum.get(numKey));
}
if (!details.role) {
const role = lookupRole(index, { identifier, numericId });
if (role) details.role = role;
}
if (Object.keys(details).length === 0) {
return null;
}
return {
role: details.role ?? null,
shortName: details.shortName ?? null,
longName: details.longName ?? null,
};
}
/**
* Gather role hints from neighbor entries into the provided index.
*
* @param {{
* byId: Map<string, string>,
* byNum: Map<number, string>,
* detailsById: Map<string, Object>,
* detailsByNum: Map<number, Object>,
* }} index Role index maps.
* @param {Array<Object>} neighbors Raw neighbor entries.
* @returns {Set<string>} Normalized identifiers missing from the index.
*/
export function seedNeighborRoleIndex(index, neighbors) {
const missing = new Set();
if (!Array.isArray(neighbors)) {
return missing;
}
neighbors.forEach(entry => {
if (!entry || typeof entry !== 'object') {
return;
}
registerRoleCandidate(index, {
identifier: entry.neighbor_id ?? entry.neighborId,
numericId: entry.neighbor_num ?? entry.neighborNum,
role: entry.neighbor_role ?? entry.neighborRole,
shortName:
entry.neighbor_short_name
?? entry.neighborShortName
?? entry.neighbor?.short_name
?? entry.neighbor?.shortName
?? null,
longName:
entry.neighbor_long_name
?? entry.neighborLongName
?? entry.neighbor?.long_name
?? entry.neighbor?.longName
?? null,
});
registerRoleCandidate(index, {
identifier: entry.node_id ?? entry.nodeId,
numericId: entry.node_num ?? entry.nodeNum,
role: entry.node_role ?? entry.nodeRole,
shortName:
entry.node_short_name
?? entry.nodeShortName
?? entry.node?.short_name
?? entry.node?.shortName
?? null,
longName:
entry.node_long_name
?? entry.nodeLongName
?? entry.node?.long_name
?? entry.node?.longName
?? null,
});
if (entry.neighbor && typeof entry.neighbor === 'object') {
registerRoleCandidate(index, {
identifier: entry.neighbor.node_id ?? entry.neighbor.nodeId ?? entry.neighbor.id,
numericId: entry.neighbor.node_num ?? entry.neighbor.nodeNum ?? entry.neighbor.num,
role: entry.neighbor.role ?? entry.neighbor.roleName,
shortName: entry.neighbor.short_name ?? entry.neighbor.shortName ?? null,
longName: entry.neighbor.long_name ?? entry.neighbor.longName ?? null,
});
}
if (entry.node && typeof entry.node === 'object') {
registerRoleCandidate(index, {
identifier: entry.node.node_id ?? entry.node.nodeId ?? entry.node.id,
numericId: entry.node.node_num ?? entry.node.nodeNum ?? entry.node.num,
role: entry.node.role ?? entry.node.roleName,
shortName: entry.node.short_name ?? entry.node.shortName ?? null,
longName: entry.node.long_name ?? entry.node.longName ?? null,
});
}
const candidateIds = [
entry.neighbor_id,
entry.neighborId,
entry.node_id,
entry.nodeId,
entry.neighbor?.node_id,
entry.neighbor?.nodeId,
entry.node?.node_id,
entry.node?.nodeId,
];
candidateIds.forEach(identifier => {
const normalized = normalizeNodeId(identifier);
if (normalized && !index.byId.has(normalized)) {
missing.add(normalized);
}
});
});
return missing;
}
/**
* Fetch node metadata for the supplied identifiers and merge it into the role index.
*
* Concurrency is bounded by {@link NEIGHBOR_ROLE_FETCH_CONCURRENCY}: a fixed
* pool of worker promises iterates a shared queue, and each worker only starts
* its next request once the previous one settles. This keeps at most N
* requests in flight even when ``fetchIdMap`` contains hundreds of entries.
*
* @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.
*/
export async function fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl, contextLabel = 'node metadata') {
if (!(fetchIdMap instanceof Map) || fetchIdMap.size === 0) {
return;
}
const fetchFn = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch;
if (typeof fetchFn !== 'function') {
return;
}
const queue = Array.from(fetchIdMap.values());
if (queue.length === 0) return;
const fetchOne = async raw => {
try {
const response = await fetchFn(`/api/nodes/${encodeURIComponent(raw)}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) {
return;
}
if (!response.ok) {
throw new Error(`Failed to load node information for ${raw} (HTTP ${response.status})`);
}
const payload = await response.json();
registerRoleCandidate(index, {
identifier:
payload?.node_id
?? payload?.nodeId
?? payload?.id
?? raw,
numericId: payload?.node_num ?? payload?.nodeNum ?? payload?.num ?? null,
role: payload?.role ?? payload?.node_role ?? payload?.nodeRole ?? null,
shortName: payload?.short_name ?? payload?.shortName ?? null,
longName: payload?.long_name ?? payload?.longName ?? null,
});
} catch (error) {
console.warn(`Failed to resolve ${contextLabel}`, error);
}
};
const workerCount = Math.min(NEIGHBOR_ROLE_FETCH_CONCURRENCY, queue.length);
const workers = Array.from({ length: workerCount }, async () => {
while (queue.length > 0) {
const raw = queue.shift();
if (raw === undefined) return;
// eslint-disable-next-line no-await-in-loop
await fetchOne(raw);
}
});
await Promise.all(workers);
}
/**
* 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.
*/
export 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.
*
* @param {Object} node Normalised node payload.
* @param {Array<Object>} neighbors Neighbor entries for the node.
* @param {{ fetchImpl?: Function }} [options] Fetch overrides.
* @returns {Promise<{
* byId: Map<string, string>,
* byNum: Map<number, string>,
* detailsById: Map<string, Object>,
* detailsByNum: Map<number, Object>,
* }>} Role index maps enriched with neighbour metadata.
*/
export async function buildNeighborRoleIndex(node, neighbors, { fetchImpl } = {}) {
const index = { byId: new Map(), byNum: new Map(), detailsById: new Map(), detailsByNum: new Map() };
registerRoleCandidate(index, {
identifier: node?.nodeId ?? node?.node_id ?? node?.id ?? null,
numericId: node?.nodeNum ?? node?.node_num ?? node?.num ?? null,
role: node?.role ?? node?.rawSources?.node?.role ?? null,
shortName: node?.shortName ?? node?.short_name ?? null,
longName: node?.longName ?? node?.long_name ?? null,
});
if (node?.rawSources?.node && typeof node.rawSources.node === 'object') {
registerRoleCandidate(index, {
identifier: node.rawSources.node.node_id ?? node.rawSources.node.nodeId ?? null,
numericId: node.rawSources.node.node_num ?? node.rawSources.node.nodeNum ?? null,
role: node.rawSources.node.role ?? node.rawSources.node.node_role ?? null,
shortName: node.rawSources.node.short_name ?? node.rawSources.node.shortName ?? null,
longName: node.rawSources.node.long_name ?? node.rawSources.node.longName ?? null,
});
}
const missingNormalized = seedNeighborRoleIndex(index, neighbors);
if (missingNormalized.size === 0) {
return index;
}
const fetchIdMap = new Map();
if (Array.isArray(neighbors)) {
neighbors.forEach(entry => {
if (!entry || typeof entry !== 'object') return;
const candidates = [
entry.neighbor_id,
entry.neighborId,
entry.node_id,
entry.nodeId,
entry.neighbor?.node_id,
entry.neighbor?.nodeId,
entry.node?.node_id,
entry.node?.nodeId,
];
candidates.forEach(identifier => {
const normalized = normalizeNodeId(identifier);
if (normalized && missingNormalized.has(normalized) && !fetchIdMap.has(normalized)) {
fetchIdMap.set(normalized, identifier);
}
});
});
}
await fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl);
return index;
}
@@ -0,0 +1,139 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Render a one-row table summarising the selected node's metrics.
*
* @module node-page/single-node-table
*/
import { escapeHtml } from '../utils.js';
import { renderNodeLongNameLink } from '../node-rendering.js';
import {
fmtAlt,
fmtHumidity,
fmtPressure,
fmtTemperature,
fmtTx,
} from '../short-info-telemetry.js';
import {
formatBattery,
formatCoordinate,
formatDurationSeconds,
formatHardwareModel,
formatRelativeSeconds,
formatVoltage,
} from '../node-page-charts.js';
import { numberOrNull, stringOrNull } from '../value-helpers.js';
import { renderRoleAwareBadge } from './badge.js';
/**
* Render a condensed node table containing a single entry.
*
* @param {Object} node Normalised node payload.
* @param {Function} renderShortHtml Badge rendering implementation.
* @param {number} [referenceSeconds] Optional reference timestamp for relative metrics.
* @returns {string} HTML markup for the node table or an empty string.
*/
export function renderSingleNodeTable(node, renderShortHtml, referenceSeconds = Date.now() / 1000) {
if (!node || typeof node !== 'object' || typeof renderShortHtml !== 'function') {
return '';
}
const nodeId = stringOrNull(node.nodeId ?? node.node_id) ?? '';
const shortName = stringOrNull(node.shortName ?? node.short_name) ?? null;
const longName = stringOrNull(node.longName ?? node.long_name);
const protocol = stringOrNull(node.protocol) ?? null;
const longNameLink = renderNodeLongNameLink(longName, nodeId, { protocol });
const role = stringOrNull(node.role) ?? 'CLIENT';
const numericId = numberOrNull(node.nodeNum ?? node.node_num ?? node.num);
const badgeSource = node.rawSources?.node && typeof node.rawSources.node === 'object'
? node.rawSources.node
: node;
const badgeHtml = renderRoleAwareBadge(renderShortHtml, {
shortName,
longName,
role,
identifier: nodeId || null,
numericId,
source: badgeSource,
});
const hardware = formatHardwareModel(node.hwModel ?? node.hw_model);
const battery = formatBattery(node.battery ?? node.battery_level);
const voltage = formatVoltage(node.voltage ?? node.voltageReading);
const uptime = formatDurationSeconds(node.uptime ?? node.uptime_seconds ?? node.uptimeSeconds);
const channelUtil = node.channel_utilization ?? node.channelUtilization ?? null;
const channel = fmtTx(channelUtil, 3);
const airUtil = fmtTx(node.airUtil ?? node.air_util_tx ?? node.airUtilTx ?? null, 3);
const temperature = fmtTemperature(node.temperature ?? node.temp);
const humidity = fmtHumidity(node.humidity ?? node.relative_humidity ?? node.relativeHumidity);
const pressure = fmtPressure(node.pressure ?? node.barometric_pressure ?? node.barometricPressure);
const latitude = formatCoordinate(node.latitude ?? node.lat);
const longitude = formatCoordinate(node.longitude ?? node.lon);
const altitude = fmtAlt(node.altitude ?? node.alt, 'm');
const lastSeen = formatRelativeSeconds(node.lastHeard ?? node.last_heard, referenceSeconds);
const lastPosition = formatRelativeSeconds(node.positionTime ?? node.position_time, referenceSeconds);
return `
<div class="nodes-table-wrapper">
<table class="nodes-detail-table" aria-label="Selected node details">
<thead>
<tr>
<th class="nodes-col nodes-col--node-id">Node ID</th>
<th class="nodes-col nodes-col--short-name">Short</th>
<th class="nodes-col nodes-col--long-name">Long Name</th>
<th class="nodes-col nodes-col--last-seen">Last Seen</th>
<th class="nodes-col nodes-col--role">Role</th>
<th class="nodes-col nodes-col--hw-model">HW Model</th>
<th class="nodes-col nodes-col--battery">Battery</th>
<th class="nodes-col nodes-col--voltage">Voltage</th>
<th class="nodes-col nodes-col--uptime">Uptime</th>
<th class="nodes-col nodes-col--channel-util">Channel Util</th>
<th class="nodes-col nodes-col--air-util-tx">Air Util Tx</th>
<th class="nodes-col nodes-col--temperature">Temperature</th>
<th class="nodes-col nodes-col--humidity">Humidity</th>
<th class="nodes-col nodes-col--pressure">Pressure</th>
<th class="nodes-col nodes-col--latitude">Latitude</th>
<th class="nodes-col nodes-col--longitude">Longitude</th>
<th class="nodes-col nodes-col--altitude">Altitude</th>
<th class="nodes-col nodes-col--last-position">Last Position</th>
</tr>
</thead>
<tbody>
<tr>
<td class="mono nodes-col nodes-col--node-id">${escapeHtml(nodeId)}</td>
<td class="nodes-col nodes-col--short-name">${badgeHtml}</td>
<td class="nodes-col nodes-col--long-name">${longNameLink}</td>
<td class="nodes-col nodes-col--last-seen">${escapeHtml(lastSeen)}</td>
<td class="nodes-col nodes-col--role">${escapeHtml(role)}</td>
<td class="nodes-col nodes-col--hw-model">${escapeHtml(hardware)}</td>
<td class="nodes-col nodes-col--battery">${escapeHtml(battery ?? '')}</td>
<td class="nodes-col nodes-col--voltage">${escapeHtml(voltage ?? '')}</td>
<td class="nodes-col nodes-col--uptime">${escapeHtml(uptime)}</td>
<td class="nodes-col nodes-col--channel-util">${escapeHtml(channel ?? '')}</td>
<td class="nodes-col nodes-col--air-util-tx">${escapeHtml(airUtil ?? '')}</td>
<td class="nodes-col nodes-col--temperature">${escapeHtml(temperature ?? '')}</td>
<td class="nodes-col nodes-col--humidity">${escapeHtml(humidity ?? '')}</td>
<td class="nodes-col nodes-col--pressure">${escapeHtml(pressure ?? '')}</td>
<td class="nodes-col nodes-col--latitude">${escapeHtml(latitude)}</td>
<td class="nodes-col nodes-col--longitude">${escapeHtml(longitude)}</td>
<td class="nodes-col nodes-col--altitude">${escapeHtml(altitude ?? '')}</td>
<td class="mono nodes-col nodes-col--last-position">${escapeHtml(lastPosition)}</td>
</tr>
</tbody>
</table>
</div>
`;
}
@@ -0,0 +1,76 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Wrapper that renders the node-detail telemetry chart grid.
*
* @module node-page/telemetry-charts
*/
import {
TELEMETRY_CHART_SPECS,
TELEMETRY_WINDOW_MS,
renderTelemetryChart,
resolveSnapshotTimestamp,
} from '../node-page-charts.js';
import { stringOrNull } from '../value-helpers.js';
/**
* Render the telemetry charts for the supplied node when telemetry snapshots
* exist.
*
* @param {Object} node Normalised node payload.
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
* @returns {string} Chart grid markup or an empty string.
*/
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
const telemetrySource = node?.rawSources?.telemetry;
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
? node.rawSources.telemetrySnapshots
: null;
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
? telemetrySource.snapshots
: null;
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
return '';
}
const entries = rawSnapshots
.map(snapshot => {
const timestamp = resolveSnapshotTimestamp(snapshot);
if (timestamp == null) return null;
return { timestamp, snapshot };
})
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
.sort((a, b) => a.timestamp - b.timestamp);
if (entries.length === 0) {
return '';
}
const isAggregated = snapshotHistory == null && aggregatedSnapshots != null;
const charts = TELEMETRY_CHART_SPECS
.map(spec => renderTelemetryChart(spec, entries, nowMs, { ...chartOptions, isAggregated }))
.filter(chart => stringOrNull(chart));
if (charts.length === 0) {
return '';
}
return `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${charts.join('')}
</div>
</section>
`;
}
@@ -0,0 +1,204 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Trace-route rendering and the role-index extension that hydrates hop names.
*
* @module node-page/traces
*/
import { numberOrNull, stringOrNull } from '../value-helpers.js';
import {
cloneRoleIndex,
fetchNodeDetailsIntoIndex,
lookupNeighborDetails,
normalizeNodeId,
registerRoleCandidate,
} from './role-index.js';
import { renderRoleAwareBadge } from './badge.js';
/**
* Normalise a trace node reference into identifier and numeric forms.
*
* @param {*} value Raw trace endpoint/hop reference.
* @returns {{ identifier: (string|null), numericId: (number|null) }|null} Normalised reference.
*/
export function normalizeTraceNodeRef(value) {
const numericId = numberOrNull(value);
const identifier = (() => {
const stringId = stringOrNull(value);
if (numericId != null) {
const hex = (numericId >>> 0).toString(16).padStart(8, '0');
return `!${hex}`;
}
return stringId;
})();
if (identifier == null && numericId == null) {
return null;
}
return { identifier, numericId };
}
/**
* Extract an ordered trace path containing the source, hops, and destination.
*
* @param {Object} trace Trace payload.
* @returns {Array<{identifier: (string|null), numericId: (number|null)}>} Normalised path entries.
*/
export function extractTracePath(trace) {
if (!trace || typeof trace !== 'object') return [];
const path = [];
const append = ref => {
const normalized = normalizeTraceNodeRef(ref);
if (!normalized) return;
path.push(normalized);
};
append(trace.src ?? trace.source ?? trace.from);
const hops = Array.isArray(trace.hops) ? trace.hops : [];
hops.forEach(append);
append(trace.dest ?? trace.destination ?? trace.to);
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.
*/
export 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.
*/
export 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.
*
* @param {Array<{identifier: (string|null), numericId: (number|null)}>} path Ordered path references.
* @param {Function} renderShortHtml Badge rendering function.
* @param {{ roleIndex?: Object|null, node?: Object|null }} options Rendering helpers.
* @returns {string} HTML fragment for the trace or ``''`` when unsuitable.
*/
export function renderTracePath(path, renderShortHtml, { roleIndex = null, node = null } = {}) {
if (!Array.isArray(path) || path.length < 2 || typeof renderShortHtml !== 'function') {
return '';
}
const nodeIdNormalized = normalizeNodeId(node?.nodeId ?? node?.node_id);
const nodeNumNormalized = numberOrNull(node?.nodeNum ?? node?.node_num ?? node?.num);
const renderBadge = ref => {
const identifier = ref?.identifier ?? null;
const numericId = ref?.numericId ?? null;
const normalizedId = normalizeNodeId(identifier);
const matchesNode =
(normalizedId && nodeIdNormalized && normalizedId === nodeIdNormalized) ||
(numericId != null && nodeNumNormalized != null && numericId === nodeNumNormalized);
let details = lookupNeighborDetails(roleIndex, { identifier, numericId }) ?? undefined;
if (matchesNode && node) {
details = {
...(details || {}),
role: node.role ?? details?.role ?? 'CLIENT',
shortName: node.shortName ?? node.short_name ?? details?.shortName ?? null,
longName: node.longName ?? node.long_name ?? details?.longName ?? null,
};
}
return renderRoleAwareBadge(renderShortHtml, {
shortName: details?.shortName ?? null,
longName: details?.longName ?? null,
role: details?.role ?? null,
identifier,
numericId,
source: details,
});
};
const items = path
.map(renderBadge)
.filter(fragment => stringOrNull(fragment));
if (items.length < 2) {
return '';
}
const arrow = '<span class="node-detail__trace-arrow" aria-hidden="true">&rarr;</span>';
return `<li class="node-detail__trace">${items.join(arrow)}</li>`;
}
/**
* Render all traceroutes associated with the node.
*
* @param {Array<Object>} traces Trace payloads.
* @param {Function} renderShortHtml Badge renderer.
* @param {{ roleIndex?: Object|null, node?: Object|null }} options Rendering helpers.
* @returns {string} HTML fragment or ``''`` when absent.
*/
export function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = null } = {}) {
if (!Array.isArray(traces) || traces.length === 0 || typeof renderShortHtml !== 'function') {
return '';
}
const items = traces
.map(trace => renderTracePath(extractTracePath(trace), renderShortHtml, { roleIndex, node }))
.filter(fragment => stringOrNull(fragment));
if (items.length === 0) {
return '';
}
return `
<section class="node-detail__section node-detail__traceroutes">
<h3>Traceroutes</h3>
<ul class="node-detail__trace-list">${items.join('')}</ul>
</section>
`;
}