diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index c859339..0142204 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -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 => `${short}`, + }); + 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) => `${short}`, {}); + assert.equal(html, '?'); +}); + +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&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 => `${short}`; + // 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) => + `${short}`; + 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 => `${short}`, + ); + // Last four hex chars of identifier, uppercased. + assert.equal(html, 'EF12'); +}); + +test('renderNeighborGroup skips entries that fail to render and returns empty when none survive', () => { + const renderShortHtml = (short, role) => `${short}`; + // 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(/
  • /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 => `${short}`; + // 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); +}); diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index 4d9e0cf..146b4b9 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -14,1486 +14,83 @@ * limitations under the License. */ -import { refreshNodeInformation } from './node-details.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 { - fmtAlt, - fmtHumidity, - fmtPressure, - fmtTemperature, - fmtTx, -} from './short-info-telemetry.js'; +/** + * Node detail page entry point. + * + * Acts as a thin barrel re-exporting the public surface assembled in the + * focused submodules under ``./node-page/`` so existing consumers + * (``views/node_detail.erb``, ``node-detail-overlay.js``, ``charts-page.js``, + * and the unit-test suite) keep working unchanged. + * + * @module node-page + */ + import { escapeHtml } from './utils.js'; -import { buildNodeDetailHref, canonicalNodeIdentifier, renderNodeLongNameLink } from './node-rendering.js'; -import { - DAY_MS, - HOUR_MS, - TELEMETRY_WINDOW_MS, - DEFAULT_CHART_DIMENSIONS, - DEFAULT_CHART_MARGIN, - TELEMETRY_CHART_SPECS, - clamp, - hexToRgba, - formatCompactDate, - formatGasResistance, - formatSeriesPointValue, - formatFrequency, - formatBattery, - formatVoltage, - formatUptime, - formatTimestamp, - padTwo, - formatMessageTimestamp, - formatHardwareModel, - formatCoordinate, - formatRelativeSeconds, - formatDurationSeconds, - formatSnr, - toTimestampMs, - resolveSnapshotTimestamp, - buildMidnightTicks, - buildHourlyTicks, - buildLinearTicks, - buildLogTicks, - formatAxisTick, - createChartDimensions, - resolveAxisX, - scaleTimestamp, - scaleValueToAxis, - collectSnapshotContainers, - classifySnapshot, - extractSnapshotValue, - buildSeriesPoints, - resolveAxisMax, - renderTelemetrySeries, - renderYAxis, - renderXAxis, - renderTelemetryChart, -} from './node-page-charts.js'; -import { fetchMessages, fetchNodesById, fetchTracesForNode } from './node-page-data.js'; import { numberOrNull, stringOrNull } from './value-helpers.js'; +import { fetchMessages, fetchTracesForNode } from './node-page-data.js'; +import { + classifySnapshot, + formatBattery, + formatCoordinate, + formatDurationSeconds, + formatFrequency, + formatHardwareModel, + formatMessageTimestamp, + formatRelativeSeconds, + formatSnr, + formatTimestamp, + formatUptime, + formatVoltage, + padTwo, +} from './node-page-charts.js'; +import { + buildNeighborRoleIndex, + cloneRoleIndex, + fetchNodeDetailsIntoIndex, + lookupNeighborDetails, + lookupRole, + normalizeNodeId, + registerRoleCandidate, + seedNeighborRoleIndex, +} from './node-page/role-index.js'; +import { + categoriseNeighbors, + renderNeighborBadge, + renderNeighborGroup, + renderNeighborGroups, +} from './node-page/neighbor-rendering.js'; +import { renderRoleAwareBadge } from './node-page/badge.js'; +import { renderSingleNodeTable } from './node-page/single-node-table.js'; +import { renderTelemetryCharts } from './node-page/telemetry-charts.js'; +import { renderMessages } from './node-page/messages.js'; +import { + buildTraceRoleIndex, + collectTraceNodeFetchMap, + extractTracePath, + normalizeTraceNodeRef, + renderTracePath, + renderTraceroutes, +} from './node-page/traces.js'; +import { renderNodeDetailHtml } from './node-page/detail-html.js'; +import { + fetchNodeDetailHtml, + initializeNodeDetailPage, + normalizeNodeReference, + parseReferencePayload, + resolveRenderShortHtml, +} from './node-page/bootstrap.js'; -const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'default' }); -const MESSAGE_LIMIT = 50; -const RENDER_WAIT_INTERVAL_MS = 20; -const RENDER_WAIT_TIMEOUT_MS = 500; -/** 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; -const TRACE_LIMIT = 200; +export { + fetchNodeDetailHtml, + initializeNodeDetailPage, + renderTelemetryCharts, +}; /** - * Render the telemetry charts for the supplied node when telemetry snapshots - * exist. - * - * @param {Object} node Normalised node payload. - * @param {{ nowMs?: number }} [options] Rendering options. - * @returns {string} Chart grid markup or an empty string. + * Test surface used by ``__tests__/node-page.test.js``. Built explicitly so + * adding or removing a public helper triggers the test that asserts on this + * map's shape. */ -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 ` -
    -
    - ${charts.join('')} -
    -
    - `; -} - -/** - * Normalise a node identifier for consistent lookups. - * - * @param {*} identifier Candidate identifier. - * @returns {string|null} Lower-case identifier or ``null`` when invalid. - */ -function normalizeNodeId(identifier) { - const value = stringOrNull(identifier); - return value ? value.toLowerCase() : null; -} - -/** - * Register a role candidate within the supplied index. - * - * @param {{ - * byId: Map, - * byNum: Map, - * detailsById: Map, - * detailsByNum: Map, - * }} index Role index maps. - * @param {{ - * identifier?: *, - * numericId?: *, - * role?: *, - * shortName?: *, - * longName?: *, - * }} payload Role candidate payload. - * @returns {void} - */ -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 current = existing instanceof Map && (keyType === 'id' ? idKey : numKey) != null - ? existing.get(keyType === 'id' ? idKey : numKey) - : 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, byNum: Map, detailsById: Map, detailsByNum: Map}} - * Cloned maps with identical entries. - */ -function cloneRoleIndex(index) { - return { - byId: index?.byId instanceof Map ? new Map(index.byId) : new Map(), - byNum: index?.byNum instanceof Map ? new Map(index.byNum) : new Map(), - detailsById: index?.detailsById instanceof Map ? new Map(index.detailsById) : new Map(), - detailsByNum: index?.detailsByNum instanceof Map ? new Map(index.detailsByNum) : new Map(), - }; -} - -/** - * Resolve a role from the provided index using identifier or numeric keys. - * - * @param {{byId?: Map, byNum?: Map}|null} index Role lookup maps. - * @param {{ identifier?: *, numericId?: * }} payload Lookup payload. - * @returns {string|null} Resolved role string or ``null`` when unavailable. - */ -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, - * detailsByNum?: Map, - * byId?: Map, - * byNum?: Map, - * }|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. - */ -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, - * byNum: Map, - * detailsById: Map, - * detailsByNum: Map, - * }} index Role index maps. - * @param {Array} neighbors Raw neighbor entries. - * @returns {Set} Normalized identifiers missing from the index. - */ -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. - * - * @param {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}} index Role index maps. - * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers. - * @param {Function} fetchImpl Fetch implementation. - * @param {string} [contextLabel='node metadata'] Context string used in warning logs. - * @returns {Promise} Completion promise. - */ -async function 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 tasks = []; - for (const [, raw] of fetchIdMap.entries()) { - const task = (async () => { - 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); - } - })(); - tasks.push(task); - } - if (tasks.length === 0) return; - const batches = []; - for (let i = 0; i < tasks.length; i += NEIGHBOR_ROLE_FETCH_CONCURRENCY) { - batches.push(tasks.slice(i, i + NEIGHBOR_ROLE_FETCH_CONCURRENCY)); - } - for (const batch of batches) { - // eslint-disable-next-line no-await-in-loop - await Promise.all(batch); - } -} - -/** - * Fetch missing neighbor role assignments using the nodes API. - * - * @param {{byId: Map, byNum: Map}} index Role index maps. - * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers. - * @param {Function} fetchImpl Fetch implementation. - * @returns {Promise} Completion promise. - */ -async function fetchMissingNeighborRoles(index, fetchIdMap, fetchImpl) { - await fetchNodeDetailsIntoIndex(index, fetchIdMap, fetchImpl, 'neighbor role'); -} - -/** - * Build an index of neighbor roles using cached data and API lookups. - * - * @param {Object} node Normalised node payload. - * @param {Array} neighbors Neighbor entries for the node. - * @param {{ fetchImpl?: Function }} [options] Fetch overrides. - * @returns {Promise<{ - * byId: Map, - * byNum: Map, - * detailsById: Map, - * detailsByNum: Map, - * }>>} Role index maps enriched with neighbour metadata. - */ -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; -} - -/** - * 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} idKeys Candidate identifier property names. - * @param {Array} numKeys Candidate numeric identifier property names. - * @returns {boolean} ``true`` when the neighbour refers to the current node. - */ -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} neighbors Raw neighbour entries. - * @returns {{heardBy: Array, weHear: Array}} Categorised neighbours. - */ -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 }; -} - -/** - * 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. - */ -function renderRoleAwareBadge(renderShortHtml, { - shortName = null, - longName = null, - role = null, - identifier = null, - numericId = null, - source = null, -} = {}) { - const resolvedIdentifier = stringOrNull(identifier); - let 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 `${escapeHtml(resolvedShort ?? fallbackShort)}`; -} - -/** - * 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. - * @returns {string} HTML snippet for the badge or an empty string. - */ -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} entries Neighbour entries included in the group. - * @param {'heardBy'|'weHear'} perspective Group perspective. - * @param {Function} renderShortHtml Badge rendering implementation. - * @returns {string} HTML markup or an empty string when no entries render. - */ -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 ? `(${escapeHtml(snrDisplay)})` : ''; - return `
  • ${badgeHtml}${snrHtml}
  • `; - }) - .filter(item => item != null); - if (items.length === 0) return ''; - return ` -
    -

    ${escapeHtml(title)}

    -
      ${items.join('')}
    -
    - `; -} - -/** - * Render neighbour information grouped by signal direction. - * - * @param {Object} node Normalised node payload. - * @param {Array} neighbors Raw neighbour entries. - * @param {Function} renderShortHtml Badge rendering implementation. - * @returns {string} HTML markup for the neighbour section. - */ -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 ` -
    -

    Neighbors

    -
    ${groups.join('')}
    -
    - `; -} - -/** - * 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. - */ -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 ` -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Node IDShortLong NameLast SeenRoleHW ModelBatteryVoltageUptimeChannel UtilAir Util TxTemperatureHumidityPressureLatitudeLongitudeAltitudeLast Position
    ${escapeHtml(nodeId)}${badgeHtml}${longNameLink}${escapeHtml(lastSeen)}${escapeHtml(role)}${escapeHtml(hardware)}${escapeHtml(battery ?? '')}${escapeHtml(voltage ?? '')}${escapeHtml(uptime)}${escapeHtml(channel ?? '')}${escapeHtml(airUtil ?? '')}${escapeHtml(temperature ?? '')}${escapeHtml(humidity ?? '')}${escapeHtml(pressure ?? '')}${escapeHtml(latitude)}${escapeHtml(longitude)}${escapeHtml(altitude ?? '')}${escapeHtml(lastPosition)}
    -
    - `; -} - -/** - * 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. - */ -function renderNodeChatEmojiHtml(symbol) { - const trimmed = String(symbol ?? '').trim(); - if (!trimmed) return ''; - return ``; -} - -/** - * 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} messages Message records. - * @param {?Object} fallbackNode Current page node. - * @param {?Map} globalNodesById Global node registry. - * @returns {Map} Lookup map keyed by node identifier. - */ -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} messages Message records. - * @param {Function} renderShortHtml Badge rendering implementation. - * @param {Object} node Node context used when message metadata is incomplete. - * @param {?Map} [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. - */ -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 `
    ${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml} ${bodyHtml}
    `; - }) - .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 ( - '
    ' - + '
    ' - + items.join('') - + '
    ' - + '
    ' - ); -} - -/** - * 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. - */ -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. - */ -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} traces Trace payloads to inspect. - * @param {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}} roleIndex Existing role index hydrated with known nodes. - * @returns {Map} Mapping of normalized identifiers to fetch payloads. - */ -function collectTraceNodeFetchMap(traces, roleIndex) { - const fetchIdMap = new Map(); - if (!Array.isArray(traces)) return fetchIdMap; - - for (const trace of traces) { - const path = extractTracePath(trace); - for (const ref of path) { - const identifier = ref?.identifier ?? null; - const numericId = ref?.numericId ?? null; - registerRoleCandidate(roleIndex, { identifier, numericId }); - const details = lookupNeighborDetails(roleIndex, { identifier, numericId }); - const hasNames = Boolean(stringOrNull(details?.shortName) || stringOrNull(details?.longName)); - if (hasNames) continue; - const normalized = normalizeNodeId(identifier); - const numericKey = numberOrNull(numericId); - const mapKey = normalized ?? (numericKey != null ? `#${numericKey}` : null); - const fetchKey = identifier ?? numericKey; - if (mapKey && fetchKey != null && !fetchIdMap.has(mapKey)) { - fetchIdMap.set(mapKey, fetchKey); - } - } - } - - return fetchIdMap; -} - -/** - * Build a role index enriched with node metadata for trace hops. - * - * @param {Array} traces Trace payloads. - * @param {{byId?: Map, byNum?: Map, detailsById?: Map, detailsByNum?: Map}} [baseIndex] - * Optional base role index to clone. - * @param {{ fetchImpl?: Function }} [options] Fetch overrides. - * @returns {Promise<{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}>} - * Hydrated role index containing hop metadata. - */ -async function buildTraceRoleIndex(traces, baseIndex = null, { fetchImpl } = {}) { - const roleIndex = cloneRoleIndex(baseIndex); - const fetchIdMap = collectTraceNodeFetchMap(traces, roleIndex); - await fetchNodeDetailsIntoIndex(roleIndex, fetchIdMap, fetchImpl, 'trace node metadata'); - return roleIndex; -} - -/** - * Render a trace path using short-name badges. - * - * @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. - */ -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 = ''; - return `
  • ${items.join(arrow)}
  • `; -} - -/** - * Render all traceroutes associated with the node. - * - * @param {Array} 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. - */ -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 ` -
    -

    Traceroutes

    -
      ${items.join('')}
    -
    - `; -} - -/** - * Render the node detail layout to an HTML fragment. - * - * @param {Object} node Normalised node payload. - * @param {{ - * neighbors?: Array, - * messages?: Array, - * traces?: Array, - * renderShortHtml: Function, - * }} options Rendering options. - * @returns {string} HTML fragment representing the detail view. - */ -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(`

    Messages

    ${messagesHtml}
    `); - } - - const identifierHtml = identifier ? `[${escapeHtml(identifier)}]` : ''; - const iconPrefix = protocolIconPrefixHtml(nodeProtocol); - const nameHtml = longName ? `${iconPrefix}${escapeHtml(longName)}` : ''; - const badgeHtml = `${roleAwareBadge}`; - const tableSection = tableHtml ? `
    ${tableHtml}
    ` : ''; - const contentHtml = sections.length > 0 ? `
    ${sections.join('')}
    ` : ''; - - return ` -
    -

    ${badgeHtml}${nameHtml}${identifierHtml}

    -
    - ${chartsHtml ?? ''} - ${tableSection} - ${contentHtml} - `; -} - -/** - * Parse the serialized reference payload embedded in the DOM. - * - * @param {string} raw Raw JSON string. - * @returns {Object|null} Parsed object or ``null`` when invalid. - */ -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. - */ -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} Badge rendering implementation. - */ -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 => `${escapeHtml(short ?? '?')}`; -} - - - -/** - * 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} ``true`` when the node was rendered successfully. - */ -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, - }); -} - -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 = '

    Node reference unavailable.

    '; - return false; - } - - const identifier = stringOrNull(referenceData.nodeId) ?? null; - const nodeNum = numberOrNull(referenceData.nodeNum); - if (!identifier && nodeNum == null) { - root.innerHTML = '

    Node identifier missing.

    '; - 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 = '

    Failed to load node details.

    '; - return false; - } -} - export const __testUtils = { stringOrNull, numberOrNull, @@ -1517,10 +114,14 @@ export const __testUtils = { lookupNeighborDetails, seedNeighborRoleIndex, buildNeighborRoleIndex, + fetchNodeDetailsIntoIndex, collectTraceNodeFetchMap, buildTraceRoleIndex, categoriseNeighbors, + renderNeighborBadge, + renderNeighborGroup, renderNeighborGroups, + renderRoleAwareBadge, renderSingleNodeTable, classifySnapshot, renderTelemetryCharts, diff --git a/web/public/assets/js/app/node-page/badge.js b/web/public/assets/js/app/node-page/badge.js new file mode 100644 index 0000000..4ff1962 --- /dev/null +++ b/web/public/assets/js/app/node-page/badge.js @@ -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 `${escapeHtml(resolvedShort ?? fallbackShort)}`; +} diff --git a/web/public/assets/js/app/node-page/bootstrap.js b/web/public/assets/js/app/node-page/bootstrap.js new file mode 100644 index 0000000..a8bcc12 --- /dev/null +++ b/web/public/assets/js/app/node-page/bootstrap.js @@ -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} 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 => `${escapeHtml(short ?? '?')}`; +} + +/** + * 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} 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} ``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 = '

    Node reference unavailable.

    '; + return false; + } + + const identifier = stringOrNull(referenceData.nodeId) ?? null; + const nodeNum = numberOrNull(referenceData.nodeNum); + if (!identifier && nodeNum == null) { + root.innerHTML = '

    Node identifier missing.

    '; + 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 = '

    Failed to load node details.

    '; + return false; + } +} diff --git a/web/public/assets/js/app/node-page/detail-html.js b/web/public/assets/js/app/node-page/detail-html.js new file mode 100644 index 0000000..d71b490 --- /dev/null +++ b/web/public/assets/js/app/node-page/detail-html.js @@ -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, + * messages?: Array, + * traces?: Array, + * renderShortHtml: Function, + * roleIndex?: Object|null, + * chartNowMs?: number, + * nodesById?: Map|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(`

    Messages

    ${messagesHtml}
    `); + } + + const identifierHtml = identifier ? `[${escapeHtml(identifier)}]` : ''; + const iconPrefix = protocolIconPrefixHtml(nodeProtocol); + const nameHtml = longName ? `${iconPrefix}${escapeHtml(longName)}` : ''; + const badgeHtml = `${roleAwareBadge}`; + const tableSection = tableHtml ? `
    ${tableHtml}
    ` : ''; + const contentHtml = sections.length > 0 ? `
    ${sections.join('')}
    ` : ''; + + return ` +
    +

    ${badgeHtml}${nameHtml}${identifierHtml}

    +
    + ${chartsHtml ?? ''} + ${tableSection} + ${contentHtml} + `; +} diff --git a/web/public/assets/js/app/node-page/messages.js b/web/public/assets/js/app/node-page/messages.js new file mode 100644 index 0000000..c3c6f48 --- /dev/null +++ b/web/public/assets/js/app/node-page/messages.js @@ -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 ``; +} + +/** + * 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} messages Message records. + * @param {?Object} fallbackNode Current page node. + * @param {?Map} globalNodesById Global node registry. + * @returns {Map} 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} messages Message records. + * @param {Function} renderShortHtml Badge rendering implementation. + * @param {Object} node Node context used when message metadata is incomplete. + * @param {?Map} [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 `
    ${prefix}${presetTag}${channelTag} ${protocolIconHtml}${badgeHtml} ${bodyHtml}
    `; + }) + .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 ( + '
    ' + + '
    ' + + items.join('') + + '
    ' + + '
    ' + ); +} diff --git a/web/public/assets/js/app/node-page/neighbor-rendering.js b/web/public/assets/js/app/node-page/neighbor-rendering.js new file mode 100644 index 0000000..8486318 --- /dev/null +++ b/web/public/assets/js/app/node-page/neighbor-rendering.js @@ -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} idKeys Candidate identifier property names. + * @param {Array} 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} neighbors Raw neighbour entries. + * @returns {{heardBy: Array, weHear: Array}} 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} 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 ? `(${escapeHtml(snrDisplay)})` : ''; + return `
  • ${badgeHtml}${snrHtml}
  • `; + }) + .filter(item => item != null); + if (items.length === 0) return ''; + return ` +
    +

    ${escapeHtml(title)}

    +
      ${items.join('')}
    +
    + `; +} + +/** + * Render neighbour information grouped by signal direction. + * + * @param {Object} node Normalised node payload. + * @param {Array} 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 ` +
    +

    Neighbors

    +
    ${groups.join('')}
    +
    + `; +} diff --git a/web/public/assets/js/app/node-page/role-index.js b/web/public/assets/js/app/node-page/role-index.js new file mode 100644 index 0000000..1e2de9e --- /dev/null +++ b/web/public/assets/js/app/node-page/role-index.js @@ -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, + * byNum: Map, + * detailsById: Map, + * detailsByNum: Map, + * }} 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, byNum: Map, detailsById: Map, detailsByNum: Map}} + * 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, byNum?: Map}|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, + * detailsByNum?: Map, + * byId?: Map, + * byNum?: Map, + * }|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, + * byNum: Map, + * detailsById: Map, + * detailsByNum: Map, + * }} index Role index maps. + * @param {Array} neighbors Raw neighbor entries. + * @returns {Set} 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, byNum: Map, detailsById: Map, detailsByNum: Map}} index Role index maps. + * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers. + * @param {Function} fetchImpl Fetch implementation. + * @param {string} [contextLabel='node metadata'] Context string used in warning logs. + * @returns {Promise} Completion promise. + */ +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, byNum: Map}} index Role index maps. + * @param {Map} fetchIdMap Mapping of normalized identifiers to raw fetch identifiers. + * @param {Function} fetchImpl Fetch implementation. + * @returns {Promise} Completion promise. + */ +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} neighbors Neighbor entries for the node. + * @param {{ fetchImpl?: Function }} [options] Fetch overrides. + * @returns {Promise<{ + * byId: Map, + * byNum: Map, + * detailsById: Map, + * detailsByNum: Map, + * }>} 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; +} diff --git a/web/public/assets/js/app/node-page/single-node-table.js b/web/public/assets/js/app/node-page/single-node-table.js new file mode 100644 index 0000000..49100e5 --- /dev/null +++ b/web/public/assets/js/app/node-page/single-node-table.js @@ -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 ` +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Node IDShortLong NameLast SeenRoleHW ModelBatteryVoltageUptimeChannel UtilAir Util TxTemperatureHumidityPressureLatitudeLongitudeAltitudeLast Position
    ${escapeHtml(nodeId)}${badgeHtml}${longNameLink}${escapeHtml(lastSeen)}${escapeHtml(role)}${escapeHtml(hardware)}${escapeHtml(battery ?? '')}${escapeHtml(voltage ?? '')}${escapeHtml(uptime)}${escapeHtml(channel ?? '')}${escapeHtml(airUtil ?? '')}${escapeHtml(temperature ?? '')}${escapeHtml(humidity ?? '')}${escapeHtml(pressure ?? '')}${escapeHtml(latitude)}${escapeHtml(longitude)}${escapeHtml(altitude ?? '')}${escapeHtml(lastPosition)}
    +
    + `; +} diff --git a/web/public/assets/js/app/node-page/telemetry-charts.js b/web/public/assets/js/app/node-page/telemetry-charts.js new file mode 100644 index 0000000..6fb1e3e --- /dev/null +++ b/web/public/assets/js/app/node-page/telemetry-charts.js @@ -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 ` +
    +
    + ${charts.join('')} +
    +
    + `; +} diff --git a/web/public/assets/js/app/node-page/traces.js b/web/public/assets/js/app/node-page/traces.js new file mode 100644 index 0000000..411b839 --- /dev/null +++ b/web/public/assets/js/app/node-page/traces.js @@ -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} traces Trace payloads to inspect. + * @param {{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}} roleIndex Existing role index hydrated with known nodes. + * @returns {Map} Mapping of normalized identifiers to fetch payloads. + */ +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} traces Trace payloads. + * @param {{byId?: Map, byNum?: Map, detailsById?: Map, detailsByNum?: Map}} [baseIndex] + * Optional base role index to clone. + * @param {{ fetchImpl?: Function }} [options] Fetch overrides. + * @returns {Promise<{byId: Map, byNum: Map, detailsById: Map, detailsByNum: Map}>} + * Hydrated role index containing hop metadata. + */ +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 = ''; + return `
  • ${items.join(arrow)}
  • `; +} + +/** + * Render all traceroutes associated with the node. + * + * @param {Array} 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 ` +
    +

    Traceroutes

    +
      ${items.join('')}
    +
    + `; +}