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