From 05efbc5f202fc68caf7e6e920d435d62b2cff06a Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:59:07 +0100 Subject: [PATCH] Refine node detail view layout (#442) * Refine node detail view layout * Refine node detail controls and formatting * Improve node detail neighbor roles and message metadata * Fix node detail neighbor metadata hydration --- .../assets/js/app/__tests__/node-page.test.js | 435 +++++-- web/public/assets/js/app/node-page.js | 1148 ++++++++++++++--- web/public/assets/styles/base.css | 62 +- web/views/layouts/app.erb | 11 +- 4 files changed, 1339 insertions(+), 317 deletions(-) diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js index f657be2..20fa3ab 100644 --- a/web/public/assets/js/app/__tests__/node-page.test.js +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -27,11 +27,22 @@ const { formatVoltage, formatUptime, formatTimestamp, - buildConfigurationEntries, - buildTelemetryEntries, - buildPositionEntries, - renderDefinitionList, - renderNeighbors, + formatMessageTimestamp, + formatHardwareModel, + formatCoordinate, + formatRelativeSeconds, + formatDurationSeconds, + formatSnr, + padTwo, + normalizeNodeId, + registerRoleCandidate, + lookupRole, + lookupNeighborDetails, + seedNeighborRoleIndex, + buildNeighborRoleIndex, + categoriseNeighbors, + renderNeighborGroups, + renderSingleNodeTable, renderMessages, renderNodeDetailHtml, parseReferencePayload, @@ -51,155 +62,238 @@ test('format helpers normalise values as expected', () => { assert.equal(formatVoltage(4.105), '4.11 V'); assert.equal(formatUptime(3661), '1h 1m 1s'); assert.match(formatTimestamp(1_700_000_000), /T/); + assert.equal(padTwo(3), '03'); + assert.equal(normalizeNodeId('!NODE'), '!node'); + const messageTimestamp = formatMessageTimestamp(1_700_000_000); + assert.equal(messageTimestamp.startsWith('2023-'), true); }); -test('buildConfigurationEntries collects modem and role details', () => { - const entries = buildConfigurationEntries({ - modemPreset: 'LongFast', - loraFreq: 915, +test('role lookup helpers normalise identifiers and register candidates', () => { + const index = { byId: new Map(), byNum: new Map() }; + registerRoleCandidate(index, { + identifier: '!NODE', + numericId: 77, role: 'ROUTER', - hwModel: 'T-Beam', - nodeNum: 7, - snr: 9.42, - lastHeard: 1_700_000_001, + shortName: 'NODE', + longName: 'Node Long', }); - assert.deepEqual(entries.map(entry => entry.label), [ - 'Modem preset', - 'LoRa frequency', - 'Role', - 'Hardware model', - 'Node number', - 'SNR', - 'Last heard', - ]); + assert.equal(index.byId.get('!node'), 'ROUTER'); + assert.equal(index.byNum.get(77), 'ROUTER'); + assert.equal(lookupRole(index, { identifier: '!node' }), 'ROUTER'); + assert.equal(lookupRole(index, { identifier: '!NODE' }), 'ROUTER'); + assert.equal(lookupRole(index, { numericId: 77 }), 'ROUTER'); + assert.equal(lookupRole(index, { identifier: '!missing' }), null); + const metadata = lookupNeighborDetails(index, { identifier: '!node', numericId: 77 }); + assert.deepEqual(metadata, { role: 'ROUTER', shortName: 'NODE', longName: 'Node Long' }); }); -test('buildTelemetryEntries merges additional metrics', () => { - const entries = buildTelemetryEntries({ - battery: 75.2, - voltage: 4.12, - uptime: 12_345, - channel: 1.23, - airUtil: 0.45, - temperature: 21.5, - humidity: 55.5, - pressure: 1013.4, - telemetry: { - current: 0.53, - gas_resistance: 10_000, - iaq: 42, - distance: 1.23, - lux: 35, - uv_lux: 3.5, - wind_direction: 180, - wind_speed: 2.5, - wind_gust: 4.1, - rainfall_1h: 0.12, - rainfall_24h: 1.02, - telemetry_time: 1_700_000_123, - }, - }); - const labels = entries.map(entry => entry.label); - assert.ok(labels.includes('Battery')); - assert.ok(labels.includes('Voltage')); - assert.ok(labels.includes('Uptime')); - assert.ok(labels.includes('Channel utilisation')); - assert.ok(labels.includes('Air util (TX)')); - assert.ok(labels.includes('Temperature')); - assert.ok(labels.includes('Humidity')); - assert.ok(labels.includes('Pressure')); - assert.ok(labels.includes('Current')); - assert.ok(labels.includes('Gas resistance')); - assert.ok(labels.includes('IAQ')); - assert.ok(labels.includes('Distance')); - assert.ok(labels.includes('Lux')); - assert.ok(labels.includes('UV index')); - assert.ok(labels.includes('Wind direction')); - assert.ok(labels.includes('Wind speed')); - assert.ok(labels.includes('Wind gust')); - assert.ok(labels.includes('Rainfall (1h)')); - assert.ok(labels.includes('Rainfall (24h)')); - assert.ok(labels.includes('Telemetry time')); +test('seedNeighborRoleIndex captures known roles and missing identifiers', () => { + const index = { byId: new Map(), byNum: new Map() }; + const missing = seedNeighborRoleIndex(index, [ + { neighbor_id: '!ALLY', neighbor_role: 'CLIENT', neighbor_short_name: 'ALLY' }, + { node_id: '!self', node_role: 'ROUTER' }, + { neighbor_id: '!unknown' }, + ]); + assert.equal(index.byId.get('!ally'), 'CLIENT'); + assert.equal(index.byId.get('!self'), 'ROUTER'); + assert.equal(missing.has('!unknown'), true); + const allyDetails = lookupNeighborDetails(index, { identifier: '!ally' }); + assert.equal(allyDetails.shortName, 'ALLY'); }); -test('buildPositionEntries includes precision metadata', () => { - const entries = buildPositionEntries({ - latitude: 52.52, - longitude: 13.405, - altitude: 42, - position: { - sats_in_view: 12, - precision_bits: 7, - location_source: 'GPS', - position_time: 1_700_000_050, - rx_time: 1_700_000_055, - }, - }); - const labels = entries.map(entry => entry.label); - assert.ok(labels.includes('Latitude')); - assert.ok(labels.includes('Longitude')); - assert.ok(labels.includes('Altitude')); - assert.ok(labels.includes('Satellites')); - assert.ok(labels.includes('Precision bits')); - assert.ok(labels.includes('Location source')); - assert.ok(labels.includes('Position time')); - assert.ok(labels.includes('RX time')); -}); +test('additional format helpers provide table friendly output', () => { + assert.equal(formatHardwareModel('UNSET'), ''); + assert.equal(formatHardwareModel('T-Beam'), 'T-Beam'); + assert.equal(formatCoordinate(52.123456), '52.12346'); + assert.equal(formatCoordinate(null), ''); + assert.equal(formatRelativeSeconds(1_000, 1_060), '1m'); + assert.equal(formatRelativeSeconds(1_000, 1_120), '2m'); + assert.equal(formatRelativeSeconds(1_000, 1_000 + 3_700), '1h 1m'); + assert.equal(formatRelativeSeconds(1_000, 1_000 + 90_000).startsWith('1d'), true); + assert.equal(formatDurationSeconds(59), '59s'); + assert.equal(formatDurationSeconds(61), '1m 1s'); + assert.equal(formatDurationSeconds(3_661), '1h 1m'); + assert.equal(formatDurationSeconds(172_800), '2d'); + assert.equal(formatSnr(12.345), '12.3 dB'); + assert.equal(formatSnr(null), ''); -test('render helpers ignore empty values', () => { - const listHtml = renderDefinitionList([ - { label: 'Valid', value: 'ok' }, - { label: 'Empty', value: '' }, - ]); - assert.equal(listHtml.includes('Valid'), true); - assert.equal(listHtml.includes('Empty'), false); - - const neighborsHtml = renderNeighbors([ - { neighbor_id: '!ally', snr: 9.5, rx_time: 1_700_000_321 }, - null, - ]); - assert.equal(neighborsHtml.includes('!ally'), true); - - const messagesHtml = renderMessages([ - { text: 'hello', rx_time: 1_700_000_400, from_id: '!src', to_id: '!dst' }, - { emoji: '😊', rx_time: 1_700_000_401 }, - ]); + const renderShortHtml = (short, role) => `${short}`; + const nodeContext = { + shortName: 'NODE', + longName: 'Node Long', + role: 'CLIENT', + nodeId: '!node', + nodeNum: 77, + rawSources: { node: { node_id: '!node', role: 'CLIENT', short_name: 'NODE' } }, + }; + const messagesHtml = renderMessages( + [ + { + text: 'hello', + rx_time: 1_700_000_400, + region_frequency: 868, + modem_preset: 'MediumFast', + channel_name: 'Primary', + node: { short_name: 'SRCE', role: 'ROUTER', node_id: '!src' }, + }, + { emoji: '😊', rx_time: 1_700_000_401 }, + ], + renderShortHtml, + nodeContext, + ); assert.equal(messagesHtml.includes('hello'), true); assert.equal(messagesHtml.includes('😊'), true); + assert.equal(messagesHtml.includes('[2023-'), true); + assert.equal(messagesHtml.includes('[868]'), true); + assert.equal(messagesHtml.includes('[MF]'), true); + assert.equal(messagesHtml.includes('[Primary]'), true); + assert.equal(messagesHtml.includes('data-role="ROUTER"'), true); + assert.equal(messagesHtml.includes('   '), true); + assert.equal(messagesHtml.includes('  '), true); + assert.equal(messagesHtml.includes('data-role="CLIENT"'), true); + assert.equal(messagesHtml.includes(', hello'), false); }); -test('renderNodeDetailHtml composes sections when data exists', () => { +test('categoriseNeighbors splits inbound and outbound records', () => { + const node = { nodeId: '!self', nodeNum: 42 }; + const neighbors = [ + { node_id: '!self', neighbor_id: '!ally-one' }, + { node_id: '!peer', neighbor_id: '!SELF' }, + { node_num: 42, neighbor_id: '!ally-two' }, + { node_id: '!friend', neighbor_num: 42 }, + null, + ]; + const { heardBy, weHear } = categoriseNeighbors(node, neighbors); + assert.equal(heardBy.length, 2); + assert.equal(weHear.length, 2); +}); + +test('renderNeighborGroups renders grouped neighbour lists', () => { + const node = { nodeId: '!self', nodeNum: 77 }; + const neighbors = [ + { + node_id: '!peer', + node_short_name: 'PEER', + neighbor_id: '!self', + snr: 9.5, + node: { short_name: 'PEER', role: 'ROUTER' }, + }, + { + node_id: '!self', + neighbor_id: '!ally', + neighbor_short_name: 'ALLY', + snr: 5.25, + neighbor: { short_name: 'ALLY', role: 'REPEATER' }, + }, + ]; + const html = renderNeighborGroups( + node, + neighbors, + (short, role) => `${short}`, + ); + assert.equal(html.includes('Neighbors'), true); + assert.equal(html.includes('Heard by'), true); + assert.equal(html.includes('We hear'), true); + assert.equal(html.includes('PEER'), true); + assert.equal(html.includes('ALLY'), true); + assert.equal(html.includes('9.5 dB'), true); + assert.equal(html.includes('5.3 dB'), true); + assert.equal(html.includes('data-role="ROUTER"'), true); + assert.equal(html.includes('data-role="REPEATER"'), true); +}); + +test('buildNeighborRoleIndex fetches missing neighbor metadata from the API', async () => { + const neighbors = [ + { neighbor_id: '!ally', neighbor_short_name: 'ALLY' }, + ]; + const calls = []; + const fetchImpl = async url => { + calls.push(url); + return { + status: 200, + ok: true, + json: async () => ({ node_id: '!ally', role: 'ROUTER', node_num: 99, short_name: 'ALLY-API' }), + }; + }; + const index = await buildNeighborRoleIndex({ nodeId: '!self', role: 'CLIENT' }, neighbors, { fetchImpl }); + assert.equal(index.byId.get('!self'), 'CLIENT'); + assert.equal(index.byId.get('!ally'), 'ROUTER'); + assert.equal(index.byNum.get(99), 'ROUTER'); + assert.equal(calls.some(url => url.startsWith('/api/nodes/')), true); + const allyMetadata = lookupNeighborDetails(index, { identifier: '!ally', numericId: 99 }); + assert.equal(allyMetadata.shortName, 'ALLY-API'); +}); + +test('renderSingleNodeTable renders a condensed table for the node', () => { + const node = { + shortName: 'NODE', + longName: 'Example Node', + nodeId: '!abcd', + role: 'CLIENT', + hwModel: 'T-Beam', + battery: 66, + voltage: 4.12, + uptime: 3_700, + channel: 1.23, + airUtil: 0.45, + temperature: 22.5, + humidity: 55.5, + pressure: 1_013.2, + latitude: 52.52, + longitude: 13.405, + altitude: 40, + lastHeard: 9_900, + positionTime: 9_850, + rawSources: { node: { node_id: '!abcd', role: 'CLIENT' } }, + }; + const html = renderSingleNodeTable( + node, + (short, role) => `${short}`, + 10_000, + ); + assert.equal(html.includes(' { const html = renderNodeDetailHtml( { shortName: 'NODE', longName: 'Example Node', nodeId: '!abcd', + nodeNum: 77, role: 'CLIENT', - modemPreset: 'LongFast', - loraFreq: 915, battery: 60, voltage: 4.1, uptime: 1_000, - temperature: 22, - humidity: 50, - pressure: 1005, latitude: 52.5, longitude: 13.4, altitude: 40, }, { - neighbors: [{ neighbor_id: '!ally', snr: 7.5 }], + neighbors: [ + { node_id: '!peer', node_short_name: 'PEER', neighbor_id: '!abcd', snr: 7.5 }, + { node_id: '!abcd', neighbor_id: '!ally', neighbor_short_name: 'ALLY', snr: 5.1 }, + ], messages: [{ text: 'Hello', rx_time: 1_700_000_111 }], renderShortHtml: (short, role) => `${short}`, }, ); - assert.equal(html.includes('Configuration'), true); - assert.equal(html.includes('Telemetry'), true); - assert.equal(html.includes('Position'), true); + assert.equal(html.includes('node-detail__table'), true); assert.equal(html.includes('Neighbors'), true); + assert.equal(html.includes('Heard by'), true); + assert.equal(html.includes('We hear'), true); assert.equal(html.includes('Messages'), true); assert.equal(html.includes('Example Node'), true); - assert.equal(html.includes('!ally'), true); + assert.equal(html.includes('PEER'), true); + assert.equal(html.includes('ALLY'), true); + assert.equal(html.includes('[2023'), true); + assert.equal(html.includes('data-role="CLIENT"'), true); }); test('parseReferencePayload returns null for invalid JSON', () => { @@ -277,15 +371,27 @@ test('initializeNodeDetailPage hydrates the container with node data', async () latitude: 52.5, longitude: 13.4, altitude: 42, - neighbors: [{ neighbor_id: '!ally', snr: 5.5 }], + neighbors: [{ node_id: '!node', neighbor_id: '!ally', snr: 5.5 }], rawSources: { node: { node_id: '!node', role: 'CLIENT' } }, }; }; - const fetchImpl = async () => ({ - status: 200, - ok: true, - json: async () => [{ text: 'hello', rx_time: 1_700_000_222 }], - }); + const fetchImpl = async url => { + if (url.startsWith('/api/messages/')) { + return { + status: 200, + ok: true, + json: async () => [{ text: 'hello', rx_time: 1_700_000_222 }], + }; + } + if (url.startsWith('/api/nodes/')) { + return { + status: 200, + ok: true, + json: async () => ({ node_id: '!ally', role: 'ROUTER', short_name: 'ALLY-API' }), + }; + } + return { status: 404, ok: false, json: async () => ({}) }; + }; const renderShortHtml = short => `${short}`; const result = await initializeNodeDetailPage({ document: documentStub, @@ -295,8 +401,85 @@ test('initializeNodeDetailPage hydrates the container with node data', async () }); assert.equal(result, true); assert.equal(element.innerHTML.includes('Node Long'), true); + assert.equal(element.innerHTML.includes('node-detail__table'), true); assert.equal(element.innerHTML.includes('Neighbors'), true); assert.equal(element.innerHTML.includes('Messages'), true); + assert.equal(element.innerHTML.includes('ALLY-API'), true); +}); + +test('initializeNodeDetailPage removes legacy filter controls when supported', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }), + privateMode: 'false', + }, + innerHTML: '', + }; + const filterContainer = { + removed: false, + remove() { + this.removed = true; + }, + }; + const documentStub = { + querySelector: selector => { + if (selector === '#nodeDetail') return element; + if (selector === '.filter-input') return filterContainer; + return null; + }, + }; + const refreshImpl = async () => ({ + shortName: 'NODE', + nodeId: '!node', + role: 'CLIENT', + neighbors: [], + rawSources: { node: { node_id: '!node', role: 'CLIENT' } }, + }); + const fetchImpl = async () => ({ status: 404, ok: false }); + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + fetchImpl, + renderShortHtml, + }); + assert.equal(result, true); + assert.equal(filterContainer.removed, true); +}); + +test('initializeNodeDetailPage hides legacy filter controls when removal is unavailable', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }), + privateMode: 'false', + }, + innerHTML: '', + }; + const filterContainer = { hidden: false }; + const documentStub = { + querySelector: selector => { + if (selector === '#nodeDetail') return element; + if (selector === '.filter-input') return filterContainer; + return null; + }, + }; + const refreshImpl = async () => ({ + shortName: 'NODE', + nodeId: '!node', + role: 'CLIENT', + neighbors: [], + rawSources: { node: { node_id: '!node', role: 'CLIENT' } }, + }); + const fetchImpl = async () => ({ status: 404, ok: false }); + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + fetchImpl, + renderShortHtml, + }); + assert.equal(result, true); + assert.equal(filterContainer.hidden, true); }); test('initializeNodeDetailPage reports an error when refresh fails', async () => { diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js index e3ec1fe..6b25bc7 100644 --- a/web/public/assets/js/app/node-page.js +++ b/web/public/assets/js/app/node-page.js @@ -15,24 +15,25 @@ */ import { refreshNodeInformation } from './node-details.js'; +import { + extractChatMessageMetadata, + formatChatChannelTag, + formatChatMessagePrefix, + formatChatPresetTag, +} from './chat-format.js'; import { fmtAlt, fmtHumidity, fmtPressure, fmtTemperature, fmtTx, - fmtCurrent, - fmtGasResistance, - fmtDistance, - fmtLux, - fmtWindDirection, - fmtWindSpeed, } from './short-info-telemetry.js'; const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' }); const MESSAGE_LIMIT = 50; const RENDER_WAIT_INTERVAL_MS = 20; const RENDER_WAIT_TIMEOUT_MS = 500; +const NEIGHBOR_ROLE_FETCH_CONCURRENCY = 4; /** * Convert a candidate value into a trimmed string. @@ -168,192 +169,945 @@ function formatTimestamp(value, isoFallback = null) { } /** - * Build the configuration definition list entries for the provided node. + * Pad a numeric value with leading zeros. * - * @param {Object} node Normalised node payload. - * @returns {Array<{label: string, value: string}>} Configuration entries. + * @param {number} value Numeric value to pad. + * @returns {string} Padded string representation. */ -function buildConfigurationEntries(node) { - const entries = []; - if (!node || typeof node !== 'object') return entries; - const modem = stringOrNull(node.modemPreset ?? node.modem_preset); - if (modem) entries.push({ label: 'Modem preset', value: modem }); - const freq = formatFrequency(node.loraFreq ?? node.lora_freq); - if (freq) entries.push({ label: 'LoRa frequency', value: freq }); - const role = stringOrNull(node.role); - if (role) entries.push({ label: 'Role', value: role }); - const hwModel = stringOrNull(node.hwModel ?? node.hw_model); - if (hwModel) entries.push({ label: 'Hardware model', value: hwModel }); - const nodeNum = numberOrNull(node.nodeNum ?? node.node_num ?? node.num); - if (nodeNum != null) entries.push({ label: 'Node number', value: String(nodeNum) }); - const snr = numberOrNull(node.snr); - if (snr != null) entries.push({ label: 'SNR', value: `${snr.toFixed(1)} dB` }); - const lastSeen = formatTimestamp(node.lastHeard, node.lastSeenIso ?? node.last_seen_iso); - if (lastSeen) entries.push({ label: 'Last heard', value: lastSeen }); - return entries; +function padTwo(value) { + return String(Math.trunc(Math.abs(Number(value)))).padStart(2, '0'); } /** - * Build telemetry entries incorporating additional environmental metrics. + * Format a timestamp for the message log using ``YYYY-MM-DD HH:MM:SS``. * - * @param {Object} node Normalised node payload. - * @returns {Array<{label: string, value: string}>} Telemetry entries. + * @param {*} value Seconds since the epoch. + * @param {string|null} isoFallback ISO timestamp to prefer when available. + * @returns {string|null} Formatted timestamp string or ``null``. */ -function buildTelemetryEntries(node) { - const entries = []; - if (!node || typeof node !== 'object') return entries; - const battery = formatBattery(node.battery ?? node.battery_level); - if (battery) entries.push({ label: 'Battery', value: battery }); - const voltage = formatVoltage(node.voltage); - if (voltage) entries.push({ label: 'Voltage', value: voltage }); - const uptime = formatUptime(node.uptime ?? node.uptime_seconds); - if (uptime) entries.push({ label: 'Uptime', value: uptime }); - const channel = fmtTx(node.channel ?? node.channel_utilization ?? node.channelUtilization ?? null, 3); - if (channel) entries.push({ label: 'Channel utilisation', value: channel }); - const airUtil = fmtTx(node.airUtil ?? node.air_util_tx ?? node.airUtilTx ?? null, 3); - if (airUtil) entries.push({ label: 'Air util (TX)', value: airUtil }); - const temperature = fmtTemperature(node.temperature ?? node.temp); - if (temperature) entries.push({ label: 'Temperature', value: temperature }); - const humidity = fmtHumidity(node.humidity ?? node.relative_humidity ?? node.relativeHumidity); - if (humidity) entries.push({ label: 'Humidity', value: humidity }); - const pressure = fmtPressure(node.pressure ?? node.barometric_pressure ?? node.barometricPressure); - if (pressure) entries.push({ label: 'Pressure', value: pressure }); - - const telemetry = node.telemetry && typeof node.telemetry === 'object' ? node.telemetry : {}; - const current = fmtCurrent(telemetry.current); - if (current) entries.push({ label: 'Current', value: current }); - const gas = fmtGasResistance(telemetry.gas_resistance ?? telemetry.gasResistance); - if (gas) entries.push({ label: 'Gas resistance', value: gas }); - const iaq = numberOrNull(telemetry.iaq); - if (iaq != null) entries.push({ label: 'IAQ', value: String(Math.round(iaq)) }); - const distance = fmtDistance(telemetry.distance); - if (distance) entries.push({ label: 'Distance', value: distance }); - const lux = fmtLux(telemetry.lux); - if (lux) entries.push({ label: 'Lux', value: lux }); - const uv = fmtLux(telemetry.uv_lux ?? telemetry.uvLux); - if (uv) entries.push({ label: 'UV index', value: uv }); - const windDir = fmtWindDirection(telemetry.wind_direction ?? telemetry.windDirection); - if (windDir) entries.push({ label: 'Wind direction', value: windDir }); - const windSpeed = fmtWindSpeed(telemetry.wind_speed ?? telemetry.windSpeed); - if (windSpeed) entries.push({ label: 'Wind speed', value: windSpeed }); - const windGust = fmtWindSpeed(telemetry.wind_gust ?? telemetry.windGust); - if (windGust) entries.push({ label: 'Wind gust', value: windGust }); - const rainfallHour = fmtDistance(telemetry.rainfall_1h ?? telemetry.rainfall1h); - if (rainfallHour) entries.push({ label: 'Rainfall (1h)', value: rainfallHour }); - const rainfallDay = fmtDistance(telemetry.rainfall_24h ?? telemetry.rainfall24h); - if (rainfallDay) entries.push({ label: 'Rainfall (24h)', value: rainfallDay }); - - const telemetryTimestamp = formatTimestamp( - telemetry.telemetry_time ?? node.telemetryTime, - telemetry.telemetry_time_iso ?? node.telemetryTimeIso, - ); - if (telemetryTimestamp) entries.push({ label: 'Telemetry time', value: telemetryTimestamp }); - - return entries; +function formatMessageTimestamp(value, isoFallback = null) { + const iso = stringOrNull(isoFallback); + let date = null; + if (iso) { + const candidate = new Date(iso); + if (!Number.isNaN(candidate.getTime())) { + date = candidate; + } + } + if (!date) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + const candidate = new Date(numeric * 1000); + if (Number.isNaN(candidate.getTime())) { + return null; + } + date = candidate; + } + const year = date.getFullYear(); + const month = padTwo(date.getMonth() + 1); + const day = padTwo(date.getDate()); + const hours = padTwo(date.getHours()); + const minutes = padTwo(date.getMinutes()); + const seconds = padTwo(date.getSeconds()); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } /** - * Build the positional metadata entries for the provided node. +/** + * Format a hardware model string while hiding unset placeholders. * - * @param {Object} node Normalised node payload. - * @returns {Array<{label: string, value: string}>} Position entries. + * @param {*} value Raw hardware model value. + * @returns {string} Sanitised hardware model string. */ -function buildPositionEntries(node) { - const entries = []; - if (!node || typeof node !== 'object') return entries; - const latitude = numberOrNull(node.latitude ?? node.lat); - if (latitude != null) entries.push({ label: 'Latitude', value: latitude.toFixed(6) }); - const longitude = numberOrNull(node.longitude ?? node.lon); - if (longitude != null) entries.push({ label: 'Longitude', value: longitude.toFixed(6) }); - const altitude = fmtAlt(node.altitude ?? node.alt, ' m'); - if (altitude) entries.push({ label: 'Altitude', value: altitude }); - - const position = node.position && typeof node.position === 'object' ? node.position : {}; - const sats = numberOrNull(position.sats_in_view ?? position.satsInView); - if (sats != null) entries.push({ label: 'Satellites', value: String(sats) }); - const precision = numberOrNull(position.precision_bits ?? position.precisionBits); - if (precision != null) entries.push({ label: 'Precision bits', value: String(precision) }); - const source = stringOrNull(position.location_source ?? position.locationSource); - if (source) entries.push({ label: 'Location source', value: source }); - const positionTimestamp = formatTimestamp( - node.positionTime ?? position.position_time, - node.positionTimeIso ?? position.position_time_iso, - ); - if (positionTimestamp) entries.push({ label: 'Position time', value: positionTimestamp }); - const rxTimestamp = formatTimestamp(position.rx_time, position.rx_iso); - if (rxTimestamp) entries.push({ label: 'RX time', value: rxTimestamp }); - return entries; +function formatHardwareModel(value) { + const text = stringOrNull(value); + if (!text || text.toUpperCase() === 'UNSET') { + return ''; + } + return text; } /** - * Render a definition list as HTML. + * Format a coordinate with consistent precision. * - * @param {Array<{label: string, value: string}>} entries Definition entries. - * @returns {string} HTML string for the definition list. + * @param {*} value Raw coordinate value. + * @param {number} [precision=5] Decimal precision applied to the coordinate. + * @returns {string} Formatted coordinate string. */ -function renderDefinitionList(entries) { +function formatCoordinate(value, precision = 5) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + return numeric.toFixed(precision); +} + +/** + * Convert an absolute timestamp into a relative time description. + * + * @param {*} value Raw timestamp expressed in seconds since the epoch. + * @param {number} [referenceSeconds] Optional reference timestamp in seconds. + * @returns {string} Relative time string or an empty string when unavailable. + */ +function formatRelativeSeconds(value, referenceSeconds = Date.now() / 1000) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + const reference = numberOrNull(referenceSeconds); + const base = reference != null ? reference : Date.now() / 1000; + const diff = Math.floor(base - numeric); + const safeDiff = Number.isFinite(diff) ? Math.max(diff, 0) : 0; + if (safeDiff < 60) return `${safeDiff}s`; + if (safeDiff < 3_600) { + const minutes = Math.floor(safeDiff / 60); + const seconds = safeDiff % 60; + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + if (safeDiff < 86_400) { + const hours = Math.floor(safeDiff / 3_600); + const minutes = Math.floor((safeDiff % 3_600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + const days = Math.floor(safeDiff / 86_400); + const hours = Math.floor((safeDiff % 86_400) / 3_600); + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; +} + +/** + * Format a duration expressed in seconds using a compact human readable form. + * + * @param {*} value Raw duration in seconds. + * @returns {string} Human readable duration string or an empty string. + */ +function formatDurationSeconds(value) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + const duration = Math.max(Math.floor(numeric), 0); + if (duration < 60) return `${duration}s`; + if (duration < 3_600) { + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + if (duration < 86_400) { + const hours = Math.floor(duration / 3_600); + const minutes = Math.floor((duration % 3_600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + const days = Math.floor(duration / 86_400); + const hours = Math.floor((duration % 86_400) / 3_600); + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; +} + +/** + * Format an SNR reading with a decibel suffix. + * + * @param {*} value Raw SNR value. + * @returns {string} Formatted SNR string or an empty string. + */ +function formatSnr(value) { + const numeric = numberOrNull(value); + if (numeric == null) return ''; + return `${numeric.toFixed(1)} dB`; +} + +/** + * 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); + } + } +} + +/** + * 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 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) { + 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 [normalized, 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 neighbor role', 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); + } +} + +/** + * 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 rows = entries - .filter(entry => stringOrNull(entry?.label) && stringOrNull(entry?.value)) - .map(entry => - `
${escapeHtml(entry.label)}
${escapeHtml(entry.value)}
`, - ); - if (rows.length === 0) return ''; - return `
${rows.join('')}
`; -} - -/** - * Render neighbor information as an unordered list. - * - * @param {Array} neighbors Neighbor records. - * @returns {string} HTML string for the neighbor section. - */ -function renderNeighbors(neighbors) { - if (!Array.isArray(neighbors) || neighbors.length === 0) return ''; - const items = neighbors + const items = entries .map(entry => { - if (!entry || typeof entry !== 'object') return null; - const neighborId = stringOrNull(entry.neighbor_id ?? entry.neighborId ?? entry.node_id ?? entry.nodeId); - const snr = numberOrNull(entry.snr); - const rx = formatTimestamp(entry.rx_time, entry.rx_iso); - const parts = []; - if (neighborId) parts.push(escapeHtml(neighborId)); - if (snr != null) parts.push(`${snr.toFixed(1)} dB`); - if (rx) parts.push(escapeHtml(rx)); - if (parts.length === 0) return null; - return `
  • ${parts.join(' — ')}
  • `; + 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 `
      ${items.join('')}
    `; + return ` +
    +

    ${escapeHtml(title)}

    +
      ${items.join('')}
    +
    + `; } /** - * Render a message list using basic formatting. + * 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 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 channel = fmtTx(node.channel ?? node.channel_utilization ?? node.channelUtilization ?? null, 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}${escapeHtml(longName)}${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 a message list using structured metadata formatting. * * @param {Array} messages Message records. + * @param {Function} renderShortHtml Badge rendering implementation. + * @param {Object} node Node context used when message metadata is incomplete. * @returns {string} HTML string for the messages section. */ -function renderMessages(messages) { +function renderMessages(messages, renderShortHtml, node) { if (!Array.isArray(messages) || messages.length === 0) return ''; + + const fallbackNode = node && typeof node === 'object' ? node : null; + const items = messages .map(message => { if (!message || typeof message !== 'object') return null; const text = stringOrNull(message.text) || stringOrNull(message.emoji); if (!text) return null; - const rx = formatTimestamp(message.rx_time, message.rx_iso); - const fromId = stringOrNull(message.from_id ?? message.fromId); - const toId = stringOrNull(message.to_id ?? message.toId); - const parts = []; - if (rx) parts.push(escapeHtml(rx)); - if (fromId || toId) { - const route = [fromId, toId].filter(Boolean).map(escapeHtml).join(' → '); - if (route) parts.push(route); + + 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); + } + } } - parts.push(escapeHtml(text)); - return `
  • ${parts.join(' — ')}
  • `; + + 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 }); + + const messageNode = message.node && typeof message.node === 'object' ? message.node : null; + const badgeHtml = renderRoleAwareBadge(renderShortHtml, { + shortName: messageNode?.short_name ?? messageNode?.shortName ?? fallbackNode?.shortName ?? fallbackNode?.short_name, + longName: messageNode?.long_name ?? messageNode?.longName ?? fallbackNode?.longName ?? fallbackNode?.long_name, + role: messageNode?.role ?? fallbackNode?.role ?? null, + identifier: + message.node_id + ?? message.nodeId + ?? message.from_id + ?? message.fromId + ?? fallbackNode?.nodeId + ?? fallbackNode?.node_id + ?? null, + numericId: + message.node_num + ?? message.nodeNum + ?? message.from_num + ?? message.fromNum + ?? fallbackNode?.nodeNum + ?? fallbackNode?.node_num + ?? null, + source: messageNode ?? fallbackNode?.rawSources?.node ?? fallbackNode, + }); + + return `
  • ${prefix}${presetTag}${channelTag} ${badgeHtml} ${escapeHtml(text)}
  • `; }) .filter(item => item != null); if (items.length === 0) return ''; @@ -371,31 +1125,29 @@ function renderMessages(messages) { * }} options Rendering options. * @returns {string} HTML fragment representing the detail view. */ -function renderNodeDetailHtml(node, { neighbors = [], messages = [], renderShortHtml }) { - const roleAwareBadge = typeof renderShortHtml === 'function' - ? renderShortHtml(node.shortName ?? node.short_name, node.role, node.longName ?? node.long_name, node.rawSources?.node ?? node) - : escapeHtml(node.shortName ?? node.short_name ?? '?'); +function renderNodeDetailHtml(node, { + neighbors = [], + messages = [], + renderShortHtml, + neighborRoleIndex = 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 configHtml = renderDefinitionList(buildConfigurationEntries(node)); - const telemetryHtml = renderDefinitionList(buildTelemetryEntries(node)); - const positionHtml = renderDefinitionList(buildPositionEntries(node)); - const neighborsHtml = renderNeighbors(neighbors); - const messagesHtml = renderMessages(messages); + const tableHtml = renderSingleNodeTable(node, renderShortHtml); + const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex: neighborRoleIndex }); + const messagesHtml = renderMessages(messages, renderShortHtml, node); const sections = []; - if (configHtml) { - sections.push(`

    Configuration

    ${configHtml}
    `); - } - if (telemetryHtml) { - sections.push(`

    Telemetry

    ${telemetryHtml}
    `); - } - if (positionHtml) { - sections.push(`

    Position

    ${positionHtml}
    `); - } - if (Array.isArray(neighbors) && neighbors.length > 0 && neighborsHtml) { - sections.push(`

    Neighbors

    ${neighborsHtml}
    `); + if (neighborsHtml) { + sections.push(neighborsHtml); } if (Array.isArray(messages) && messages.length > 0 && messagesHtml) { sections.push(`

    Messages

    ${messagesHtml}
    `); @@ -403,14 +1155,16 @@ function renderNodeDetailHtml(node, { neighbors = [], messages = [], renderShort const identifierHtml = identifier ? `[${escapeHtml(identifier)}]` : ''; const nameHtml = longName ? `${escapeHtml(longName)}` : ''; + const badgeHtml = `${roleAwareBadge}`; + const tableSection = tableHtml ? `
    ${tableHtml}
    ` : ''; + const contentHtml = sections.length > 0 ? `
    ${sections.join('')}
    ` : ''; return `
    -

    ${roleAwareBadge}${nameHtml}${identifierHtml}

    +

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

    -
    - ${sections.join('')} -
    + ${tableSection} + ${contentHtml} `; } @@ -496,6 +1250,17 @@ export async function initializeNodeDetailPage(options = {}) { 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.

    '; @@ -515,6 +1280,9 @@ export async function initializeNodeDetailPage(options = {}) { try { const node = await refreshImpl(referenceData, { fetchImpl: options.fetchImpl }); + const neighborRoleIndex = await buildNeighborRoleIndex(node, node.neighbors, { + fetchImpl: options.fetchImpl, + }); const messages = await fetchMessages(identifier ?? node.nodeId ?? node.node_id ?? nodeNum, { fetchImpl: options.fetchImpl, privateMode, @@ -523,6 +1291,7 @@ export async function initializeNodeDetailPage(options = {}) { neighbors: node.neighbors, messages, renderShortHtml, + neighborRoleIndex, }); root.innerHTML = html; return true; @@ -542,11 +1311,22 @@ export const __testUtils = { formatVoltage, formatUptime, formatTimestamp, - buildConfigurationEntries, - buildTelemetryEntries, - buildPositionEntries, - renderDefinitionList, - renderNeighbors, + formatMessageTimestamp, + formatHardwareModel, + formatCoordinate, + formatRelativeSeconds, + formatDurationSeconds, + formatSnr, + padTwo, + normalizeNodeId, + registerRoleCandidate, + lookupRole, + lookupNeighborDetails, + seedNeighborRoleIndex, + buildNeighborRoleIndex, + categoriseNeighbors, + renderNeighborGroups, + renderSingleNodeTable, renderMessages, renderNodeDetailHtml, parseReferencePayload, diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 5091470..20cb03b 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -780,13 +780,14 @@ body.view-map .map-panel--full #map { } .node-detail { - max-width: 960px; - margin: 0 auto; - padding: 24px 20px 40px; + width: 100%; + margin: 0; + padding: 24px 0 40px; } .node-detail__header { margin-bottom: 12px; + padding: 0 20px; } .node-detail__title { @@ -802,6 +803,15 @@ body.view-map .map-panel--full #map { font-size: 1.2rem; } +.node-detail__table { + margin: 20px 0 32px; + overflow-x: auto; +} + +.node-detail__table table { + width: 100%; +} + .node-detail__identifier { font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--muted); @@ -811,6 +821,7 @@ body.view-map .map-panel--full #map { .node-detail__error, .node-detail__noscript { margin: 16px 0; + padding: 0 20px; } .node-detail__error { @@ -820,6 +831,51 @@ body.view-map .map-panel--full #map { .node-detail__content { display: grid; gap: 24px; + padding: 0 20px; +} + +.node-detail__neighbors-grid { + display: grid; + gap: 16px; +} + +@media (min-width: 720px) { + .node-detail__neighbors-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.node-detail__neighbors-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.node-detail__neighbors-title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.node-detail__neighbors-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.node-detail__neighbors-list li { + display: flex; + align-items: center; + gap: 10px; +} + +.node-detail__neighbor-snr { + font-family: ui-monospace, Menlo, Consolas, monospace; + color: var(--muted); + font-size: 0.95rem; } .node-detail__section h3 { diff --git a/web/views/layouts/app.erb b/web/views/layouts/app.erb index 13eb5c9..76f67d1 100644 --- a/web/views/layouts/app.erb +++ b/web/views/layouts/app.erb @@ -80,6 +80,7 @@ show_auto_fit_toggle = %i[dashboard map].include?(view_mode) show_info_button = !full_screen_view show_footer = !full_screen_view + show_filter_input = !%i[node_detail].include?(view_mode) controls_classes = ["controls"] controls_classes << "controls--full-screen" if full_screen_view refresh_row_classes = ["refresh-row"] @@ -129,10 +130,12 @@ <% if show_auto_fit_toggle %> <% end %> -
    - - -
    + <% if show_filter_input %> +
    + + +
    + <% end %> <% if show_info_button %>