diff --git a/web/lib/potato_mesh/application/routes/root.rb b/web/lib/potato_mesh/application/routes/root.rb index ba493ca..81a5189 100644 --- a/web/lib/potato_mesh/application/routes/root.rb +++ b/web/lib/potato_mesh/application/routes/root.rb @@ -42,33 +42,107 @@ module PotatoMesh # # @param template [Symbol] identifier for the ERB template. # @param view_mode [Symbol, String] logical view identifier for CSS hooks. + # @param extra_locals [Hash] additional locals merged into the rendering context. # @return [String] rendered ERB output. - def render_root_view(template, view_mode: :dashboard) + def render_root_view(template, view_mode: :dashboard, extra_locals: {}) meta = meta_configuration config = frontend_app_config theme = resolve_initial_theme view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode - erb template, layout: :"layouts/app", locals: { - site_name: meta[:name], - meta_title: meta[:title], - meta_name: meta[:name], - meta_description: meta[:description], - channel: sanitized_channel, - frequency: sanitized_frequency, - map_center_lat: PotatoMesh::Config.map_center_lat, - map_center_lon: PotatoMesh::Config.map_center_lon, - max_distance_km: PotatoMesh::Config.max_distance_km, - contact_link: sanitized_contact_link, - contact_link_url: sanitized_contact_link_url, - version: app_constant(:APP_VERSION), - private_mode: private_mode?, - federation_enabled: federation_enabled?, - refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, - app_config_json: JSON.generate(config), - initial_theme: theme, - current_view_mode: view_mode_sym, - } + base_locals = { + site_name: meta[:name], + meta_title: meta[:title], + meta_name: meta[:name], + meta_description: meta[:description], + channel: sanitized_channel, + frequency: sanitized_frequency, + map_center_lat: PotatoMesh::Config.map_center_lat, + map_center_lon: PotatoMesh::Config.map_center_lon, + max_distance_km: PotatoMesh::Config.max_distance_km, + contact_link: sanitized_contact_link, + contact_link_url: sanitized_contact_link_url, + version: app_constant(:APP_VERSION), + private_mode: private_mode?, + federation_enabled: federation_enabled?, + refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds, + app_config_json: JSON.generate(config), + initial_theme: theme, + current_view_mode: view_mode_sym, + } + sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {} + merged_locals = base_locals.merge(sanitized_locals) + + erb template, layout: :"layouts/app", locals: merged_locals + end + + # Remove keys with +nil+ values from the provided hash, returning a + # shallow copy. Hash#compact is only available in newer Ruby + # versions; this helper keeps behaviour consistent across supported + # releases. + # + # @param value [Hash, nil] collection subject to filtering. + # @return [Hash] hash excluding +nil+ values. + def reject_nil_values(value) + return {} unless value.is_a?(Hash) + + value.each_with_object({}) do |(key, entry), memo| + memo[key] = entry unless entry.nil? + end + end + + # Assemble the payload embedded into the node detail view. The + # payload provides a canonical identifier alongside any cached node, + # telemetry, or position rows that may already exist in the + # database. When no persisted data is available the method returns + # +nil+ so the caller can surface a 404 error. + # + # @param node_ref [Object] raw node identifier from the request. + # @return [Hash, nil] structured node reference payload or nil when + # the node cannot be located. + def build_node_detail_reference(node_ref) + tokens = canonical_node_parts(node_ref) + search_ref = tokens ? tokens.first : node_ref + + node_row = query_nodes(1, node_ref: search_ref).first + telemetry_row = query_telemetry(1, node_ref: search_ref).first + position_row = query_positions(1, node_ref: search_ref).first + + candidates = [node_row, telemetry_row, position_row].compact + return nil if candidates.empty? + + canonical_id = string_or_nil(node_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(telemetry_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(position_row&.fetch("node_id", nil)) + canonical_id ||= string_or_nil(tokens&.fetch(0, nil)) + if canonical_id + canonical_id = canonical_id.start_with?("!") ? canonical_id : "!#{canonical_id}" + end + return nil unless canonical_id + + numeric_id = coerce_integer(node_row&.fetch("num", nil)) + numeric_id ||= coerce_integer(telemetry_row&.fetch("node_num", nil)) + numeric_id ||= coerce_integer(position_row&.fetch("node_num", nil)) + numeric_id ||= tokens&.fetch(1, nil) + + short_id = string_or_nil(node_row&.fetch("short_name", nil)) + short_id ||= string_or_nil(telemetry_row&.fetch("short_name", nil)) + short_id ||= string_or_nil(position_row&.fetch("short_name", nil)) + short_id ||= tokens&.fetch(2, nil) + + fallback_row = node_row || telemetry_row || position_row + fallback = fallback_row ? compact_api_row(fallback_row) : nil + telemetry = telemetry_row ? compact_api_row(telemetry_row) : nil + position = position_row ? compact_api_row(position_row) : nil + + { + "nodeId" => canonical_id, + "nodeNum" => numeric_id, + "shortId" => short_id, + "fallback" => fallback, + "telemetry" => telemetry, + "position" => position, + } end end @@ -111,6 +185,30 @@ module PotatoMesh render_root_view(:nodes, view_mode: :nodes) end + app.get "/nodes/:id" do + node_ref = params.fetch("id", nil) + reference_payload = build_node_detail_reference(node_ref) + halt 404, "Not Found" unless reference_payload + + fallback = reference_payload["fallback"] || {} + short_name = string_or_nil(fallback["short_name"]) || reference_payload["shortId"] + long_name = string_or_nil(fallback["long_name"]) + role = string_or_nil(fallback["role"]) + canonical_id = string_or_nil(reference_payload["nodeId"]) + + render_root_view( + :node_detail, + view_mode: :node_detail, + extra_locals: { + node_reference_json: JSON.generate(reject_nil_values(reference_payload)), + node_page_short_name: short_name, + node_page_long_name: long_name, + node_page_role: role, + node_page_identifier: canonical_id, + }, + ) + end + app.get "/metrics" do content_type ::Prometheus::Client::Formats::Text::CONTENT_TYPE ::Prometheus::Client::Formats::Text.marshal(::Prometheus::Client.registry) diff --git a/web/public/assets/js/app/__tests__/node-page.test.js b/web/public/assets/js/app/__tests__/node-page.test.js new file mode 100644 index 0000000..f657be2 --- /dev/null +++ b/web/public/assets/js/app/__tests__/node-page.test.js @@ -0,0 +1,334 @@ +/* + * 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. + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { initializeNodeDetailPage, __testUtils } from '../node-page.js'; + +const { + stringOrNull, + numberOrNull, + formatFrequency, + formatBattery, + formatVoltage, + formatUptime, + formatTimestamp, + buildConfigurationEntries, + buildTelemetryEntries, + buildPositionEntries, + renderDefinitionList, + renderNeighbors, + renderMessages, + renderNodeDetailHtml, + parseReferencePayload, + resolveRenderShortHtml, + fetchMessages, +} = __testUtils; + +test('format helpers normalise values as expected', () => { + assert.equal(stringOrNull(' foo '), 'foo'); + assert.equal(stringOrNull(''), null); + assert.equal(numberOrNull('42'), 42); + assert.equal(numberOrNull('abc'), null); + assert.equal(formatFrequency(915), '915.000 MHz'); + assert.equal(formatFrequency('2400000'), '2.400 MHz'); + assert.equal(formatFrequency('custom'), 'custom'); + assert.equal(formatBattery(87.135), '87.1%'); + assert.equal(formatVoltage(4.105), '4.11 V'); + assert.equal(formatUptime(3661), '1h 1m 1s'); + assert.match(formatTimestamp(1_700_000_000), /T/); +}); + +test('buildConfigurationEntries collects modem and role details', () => { + const entries = buildConfigurationEntries({ + modemPreset: 'LongFast', + loraFreq: 915, + role: 'ROUTER', + hwModel: 'T-Beam', + nodeNum: 7, + snr: 9.42, + lastHeard: 1_700_000_001, + }); + assert.deepEqual(entries.map(entry => entry.label), [ + 'Modem preset', + 'LoRa frequency', + 'Role', + 'Hardware model', + 'Node number', + 'SNR', + 'Last heard', + ]); +}); + +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('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('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 }, + ]); + assert.equal(messagesHtml.includes('hello'), true); + assert.equal(messagesHtml.includes('😊'), true); +}); + +test('renderNodeDetailHtml composes sections when data exists', () => { + const html = renderNodeDetailHtml( + { + shortName: 'NODE', + longName: 'Example Node', + nodeId: '!abcd', + 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 }], + 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('Neighbors'), true); + assert.equal(html.includes('Messages'), true); + assert.equal(html.includes('Example Node'), true); + assert.equal(html.includes('!ally'), true); +}); + +test('parseReferencePayload returns null for invalid JSON', () => { + assert.equal(parseReferencePayload('{'), null); + assert.deepEqual(parseReferencePayload('{"nodeId":"!abc"}'), { nodeId: '!abc' }); +}); + +test('resolveRenderShortHtml prefers global implementation when available', async () => { + const original = globalThis.PotatoMesh; + try { + globalThis.PotatoMesh = { renderShortHtml: () => 'ok' }; + const fn = await resolveRenderShortHtml(); + assert.equal(fn('X'), 'ok'); + } finally { + globalThis.PotatoMesh = original; + } +}); + +test('resolveRenderShortHtml falls back when no implementation is exposed', async () => { + const original = globalThis.PotatoMesh; + try { + delete globalThis.PotatoMesh; + const fn = await resolveRenderShortHtml(); + assert.equal(typeof fn, 'function'); + assert.equal(fn('AB'), 'AB'); + } finally { + globalThis.PotatoMesh = original; + } +}); + +test('fetchMessages handles HTTP responses and uses defaults', async () => { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + return { + status: 200, + ok: true, + json: async () => [{ text: 'hi', rx_time: 1 }], + }; + }; + const messages = await fetchMessages('!node', { fetchImpl }); + assert.equal(messages.length, 1); + assert.equal(calls[0].options.cache, 'no-store'); +}); + +test('fetchMessages returns an empty list when the endpoint is missing', async () => { + const fetchImpl = async () => ({ status: 404, ok: false, json: async () => ({}) }); + const messages = await fetchMessages('!node', { fetchImpl }); + assert.deepEqual(messages, []); +}); + +test('initializeNodeDetailPage hydrates the container with node data', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }), + privateMode: 'false', + }, + innerHTML: '', + }; + const documentStub = { + querySelector: selector => (selector === '#nodeDetail' ? element : null), + }; + const refreshImpl = async reference => { + assert.equal(reference.nodeId, '!node'); + return { + shortName: 'NODE', + longName: 'Node Long', + nodeId: '!node', + role: 'CLIENT', + modemPreset: 'LongFast', + loraFreq: 915, + battery: 66, + voltage: 4.1, + uptime: 100, + latitude: 52.5, + longitude: 13.4, + altitude: 42, + neighbors: [{ 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 renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + fetchImpl, + renderShortHtml, + }); + assert.equal(result, true); + assert.equal(element.innerHTML.includes('Node Long'), true); + assert.equal(element.innerHTML.includes('Neighbors'), true); + assert.equal(element.innerHTML.includes('Messages'), true); +}); + +test('initializeNodeDetailPage reports an error when refresh fails', async () => { + const element = { + dataset: { + nodeReference: JSON.stringify({ nodeId: '!missing' }), + privateMode: 'false', + }, + innerHTML: '', + }; + const documentStub = { querySelector: () => element }; + const refreshImpl = async () => { + throw new Error('boom'); + }; + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ + document: documentStub, + refreshImpl, + renderShortHtml, + }); + assert.equal(result, false); + assert.equal(element.innerHTML.includes('Failed to load'), true); +}); + +test('initializeNodeDetailPage handles missing reference payloads', async () => { + const element = { + dataset: {}, + innerHTML: '', + }; + const documentStub = { querySelector: () => element }; + const renderShortHtml = short => `${short}`; + const result = await initializeNodeDetailPage({ document: documentStub, renderShortHtml }); + assert.equal(result, false); + assert.equal(element.innerHTML.includes('Node reference unavailable'), true); +}); diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index ce682d8..e01dd36 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -1729,6 +1729,12 @@ let messagesById = new Map(); return `${padded}`; } + const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {}); + potatoMeshNamespace.renderShortHtml = renderShortHtml; + potatoMeshNamespace.getRoleColor = getRoleColor; + potatoMeshNamespace.getRoleKey = getRoleKey; + potatoMeshNamespace.normalizeRole = normalizeRole; + /** * Escape a CSS selector fragment with a defensive fallback for * environments lacking ``CSS.escape`` support. diff --git a/web/public/assets/js/app/node-page.js b/web/public/assets/js/app/node-page.js new file mode 100644 index 0000000..e3ec1fe --- /dev/null +++ b/web/public/assets/js/app/node-page.js @@ -0,0 +1,555 @@ +/* + * 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. + */ + +import { refreshNodeInformation } from './node-details.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; + +/** + * Convert a candidate value into a trimmed string. + * + * @param {*} value Raw value. + * @returns {string|null} Trimmed string or ``null``. + */ +function stringOrNull(value) { + if (value == null) return null; + const str = String(value).trim(); + return str.length === 0 ? null : str; +} + +/** + * Attempt to coerce a value into a finite number. + * + * @param {*} value Raw value. + * @returns {number|null} Finite number or ``null``. + */ +function numberOrNull(value) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (value == null || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +/** + * Escape HTML sensitive characters from the provided string. + * + * @param {string} input Raw HTML string. + * @returns {string} Escaped HTML representation. + */ +function escapeHtml(input) { + const str = input == null ? '' : String(input); + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Format a frequency value using MHz units when a numeric reading is + * available. Non-numeric input is passed through unchanged. + * + * @param {*} value Raw frequency value. + * @returns {string|null} Formatted frequency string or ``null``. + */ +function formatFrequency(value) { + if (value == null || value === '') return null; + const numeric = numberOrNull(value); + if (numeric == null) { + return stringOrNull(value); + } + const abs = Math.abs(numeric); + if (abs >= 1_000_000) { + return `${(numeric / 1_000_000).toFixed(3)} MHz`; + } + if (abs >= 1_000) { + return `${(numeric / 1_000).toFixed(3)} MHz`; + } + return `${numeric.toFixed(3)} MHz`; +} + +/** + * Format a battery reading as a percentage with a single decimal place. + * + * @param {*} value Raw battery value. + * @returns {string|null} Formatted percentage or ``null``. + */ +function formatBattery(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + return `${numeric.toFixed(1)}%`; +} + +/** + * Format a voltage reading with two decimal places. + * + * @param {*} value Raw voltage value. + * @returns {string|null} Formatted voltage string. + */ +function formatVoltage(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + return `${numeric.toFixed(2)} V`; +} + +/** + * Convert an uptime reading in seconds to a concise human-readable string. + * + * @param {*} value Raw uptime value. + * @returns {string|null} Formatted uptime string or ``null`` when invalid. + */ +function formatUptime(value) { + const numeric = numberOrNull(value); + if (numeric == null) return null; + const seconds = Math.floor(numeric); + const parts = []; + const days = Math.floor(seconds / 86_400); + if (days > 0) parts.push(`${days}d`); + const hours = Math.floor((seconds % 86_400) / 3_600); + if (hours > 0) parts.push(`${hours}h`); + const minutes = Math.floor((seconds % 3_600) / 60); + if (minutes > 0) parts.push(`${minutes}m`); + const remainSeconds = seconds % 60; + if (parts.length === 0 || remainSeconds > 0) { + parts.push(`${remainSeconds}s`); + } + return parts.join(' '); +} + +/** + * Format a numeric timestamp expressed in seconds since the epoch. + * + * @param {*} value Raw timestamp value. + * @param {string|null} isoFallback ISO formatted string to prefer. + * @returns {string|null} ISO timestamp string. + */ +function formatTimestamp(value, isoFallback = null) { + const iso = stringOrNull(isoFallback); + if (iso) return iso; + const numeric = numberOrNull(value); + if (numeric == null) return null; + try { + return new Date(numeric * 1000).toISOString(); + } catch (error) { + return null; + } +} + +/** + * Build the configuration definition list entries for the provided node. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Configuration entries. + */ +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; +} + +/** + * Build telemetry entries incorporating additional environmental metrics. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Telemetry entries. + */ +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; +} + +/** + * Build the positional metadata entries for the provided node. + * + * @param {Object} node Normalised node payload. + * @returns {Array<{label: string, value: string}>} Position entries. + */ +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; +} + +/** + * Render a definition list as HTML. + * + * @param {Array<{label: string, value: string}>} entries Definition entries. + * @returns {string} HTML string for the definition list. + */ +function renderDefinitionList(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + return ''; + } + const rows = entries + .filter(entry => stringOrNull(entry?.label) && stringOrNull(entry?.value)) + .map(entry => + `