From d66b09ddeef865b1f323ccdca8532562c36266d7 Mon Sep 17 00:00:00 2001
From: l5y <220195275+l5yth@users.noreply.github.com>
Date: Mon, 13 Oct 2025 10:54:47 +0200
Subject: [PATCH] Ensure APIs filter stale data and refresh node details from
latest sources (#312)
* Ensure fresh API data and richer node refresh details
* Refresh map markers with latest node data
---
web/lib/potato_mesh/application/queries.rb | 54 ++-
.../__tests__/map-marker-node-info.test.js | 226 +++++++++
.../js/app/__tests__/node-details.test.js | 326 +++++++++++++
web/public/assets/js/app/main.js | 433 +++++++++++++++---
.../assets/js/app/map-marker-node-info.js | 279 +++++++++++
web/public/assets/js/app/node-details.js | 416 +++++++++++++++++
web/spec/app_spec.rb | 184 ++++++++
7 files changed, 1844 insertions(+), 74 deletions(-)
create mode 100644 web/public/assets/js/app/__tests__/map-marker-node-info.test.js
create mode 100644 web/public/assets/js/app/__tests__/node-details.test.js
create mode 100644 web/public/assets/js/app/map-marker-node-info.js
create mode 100644 web/public/assets/js/app/node-details.js
diff --git a/web/lib/potato_mesh/application/queries.rb b/web/lib/potato_mesh/application/queries.rb
index 8b7659e..a62068c 100644
--- a/web/lib/potato_mesh/application/queries.rb
+++ b/web/lib/potato_mesh/application/queries.rb
@@ -15,6 +15,29 @@
module PotatoMesh
module App
module Queries
+ MAX_QUERY_LIMIT = 1000
+
+ # Normalise a caller-provided limit to a sane, positive integer.
+ #
+ # @param limit [Object] value coerced to an integer.
+ # @param default [Integer] fallback used when coercion fails.
+ # @return [Integer] limit clamped between 1 and MAX_QUERY_LIMIT.
+ def coerce_query_limit(limit, default: 200)
+ coerced = begin
+ if limit.is_a?(Integer)
+ limit
+ else
+ Integer(limit, 10)
+ end
+ rescue ArgumentError, TypeError
+ nil
+ end
+
+ coerced = default if coerced.nil? || coerced <= 0
+ coerced = MAX_QUERY_LIMIT if coerced > MAX_QUERY_LIMIT
+ coerced
+ end
+
def node_reference_tokens(node_ref)
parts = canonical_node_parts(node_ref)
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
@@ -98,6 +121,7 @@ module PotatoMesh
end
def query_nodes(limit, node_ref: nil)
+ limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
now = Time.now.to_i
@@ -135,6 +159,13 @@ module PotatoMesh
params << limit
rows = db.execute(sql, params)
+ rows.select! do |r|
+ last_candidate = [r["last_heard"], r["position_time"], r["first_heard"]]
+ .map { |value| coerce_integer(value) }
+ .compact
+ .max
+ last_candidate && last_candidate >= min_last_heard
+ end
rows.each do |r|
r["role"] ||= "CLIENT"
lh = r["last_heard"]&.to_i
@@ -154,10 +185,15 @@ module PotatoMesh
end
def query_messages(limit, node_ref: nil)
+ limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = ["COALESCE(TRIM(m.encrypted), '') = ''"]
+ now = Time.now.to_i
+ min_rx_time = now - PotatoMesh::Config.week_seconds
+ where_clauses << "m.rx_time >= ?"
+ params << min_rx_time
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"])
@@ -257,10 +293,15 @@ module PotatoMesh
end
def query_positions(limit, node_ref: nil)
+ limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
+ now = Time.now.to_i
+ min_rx_time = now - PotatoMesh::Config.week_seconds
+ where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
+ params << min_rx_time
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
@@ -279,7 +320,6 @@ module PotatoMesh
SQL
params << limit
rows = db.execute(sql, params)
- now = Time.now.to_i
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
r["rx_time"] = rx_time if rx_time
@@ -304,10 +344,15 @@ module PotatoMesh
end
def query_neighbors(limit, node_ref: nil)
+ limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
+ now = Time.now.to_i
+ min_rx_time = now - PotatoMesh::Config.week_seconds
+ where_clauses << "COALESCE(rx_time, 0) >= ?"
+ params << min_rx_time
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
@@ -326,7 +371,6 @@ module PotatoMesh
SQL
params << limit
rows = db.execute(sql, params)
- now = Time.now.to_i
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
rx_time = now if rx_time && rx_time > now
@@ -340,10 +384,15 @@ module PotatoMesh
end
def query_telemetry(limit, node_ref: nil)
+ limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
+ now = Time.now.to_i
+ min_rx_time = now - PotatoMesh::Config.week_seconds
+ where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
+ params << min_rx_time
if node_ref
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
@@ -362,7 +411,6 @@ module PotatoMesh
SQL
params << limit
rows = db.execute(sql, params)
- now = Time.now.to_i
rows.each do |r|
rx_time = coerce_integer(r["rx_time"])
r["rx_time"] = rx_time if rx_time
diff --git a/web/public/assets/js/app/__tests__/map-marker-node-info.test.js b/web/public/assets/js/app/__tests__/map-marker-node-info.test.js
new file mode 100644
index 0000000..daf4928
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/map-marker-node-info.test.js
@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2025 l5yth
+ *
+ * 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 { attachNodeInfoRefreshToMarker, overlayToPopupNode } from '../map-marker-node-info.js';
+
+function createFakeMarker(anchor) {
+ const handlers = {};
+ return {
+ handlers,
+ on(name, handler) {
+ if (!handlers[name]) handlers[name] = [];
+ handlers[name].push(handler);
+ return this;
+ },
+ getElement() {
+ return anchor;
+ },
+ trigger(name, payload) {
+ for (const handler of handlers[name] || []) {
+ handler(payload);
+ }
+ },
+ };
+}
+
+test('attachNodeInfoRefreshToMarker refreshes markers with merged overlay details', async () => {
+ const anchor = { id: 'anchor-el' };
+ const marker = createFakeMarker(anchor);
+ const popupUpdates = [];
+ const detailCalls = [];
+ let prevented = false;
+ let stopped = false;
+ let token = 0;
+ const refreshCalls = [];
+
+ attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback: () => ({ nodeId: '!foo', shortName: 'Foo', role: 'CLIENT', neighbors: [] }),
+ refreshNodeInformation: async reference => {
+ refreshCalls.push(reference);
+ return { battery: 55.5, telemetryTime: 123, neighbors: [{ neighbor_id: '!bar', snr: 9.5 }] };
+ },
+ mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
+ createRequestToken: () => ++token,
+ isTokenCurrent: candidate => candidate === token,
+ showLoading: (el, info) => {
+ assert.equal(el, anchor);
+ assert.equal(info.nodeId, '!foo');
+ },
+ showDetails: (el, info) => {
+ detailCalls.push({ el, info });
+ },
+ showError: () => {
+ assert.fail('showError should not be invoked on success');
+ },
+ updatePopup: info => {
+ popupUpdates.push(info);
+ },
+ });
+
+ const clickEvent = {
+ originalEvent: {
+ preventDefault() {
+ prevented = true;
+ },
+ stopPropagation() {
+ stopped = true;
+ },
+ },
+ };
+
+ marker.trigger('click', clickEvent);
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.equal(prevented, true);
+ assert.equal(stopped, true);
+ assert.equal(refreshCalls.length, 1);
+ assert.deepEqual(refreshCalls[0], {
+ nodeId: '!foo',
+ fallback: { nodeId: '!foo', shortName: 'Foo', role: 'CLIENT', neighbors: [] },
+ });
+ assert.ok(popupUpdates.length >= 1);
+ const merged = popupUpdates[popupUpdates.length - 1];
+ assert.equal(merged.battery, 55.5);
+ assert.equal(merged.telemetryTime, 123);
+ assert.equal(detailCalls.length, 1);
+ assert.equal(detailCalls[0].el, anchor);
+ assert.equal(detailCalls[0].info.battery, 55.5);
+});
+
+test('attachNodeInfoRefreshToMarker surfaces errors with fallback overlays', async () => {
+ const anchor = { id: 'anchor' };
+ const marker = createFakeMarker(anchor);
+ let token = 0;
+ let errorCaptured = null;
+ let detailCalls = 0;
+ let updateCalls = 0;
+
+ attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback: () => ({ nodeId: '!oops', shortName: 'Oops' }),
+ refreshNodeInformation: async () => {
+ throw new Error('boom');
+ },
+ mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
+ createRequestToken: () => ++token,
+ isTokenCurrent: candidate => candidate === token,
+ showLoading: () => {},
+ showDetails: () => {
+ detailCalls += 1;
+ },
+ showError: (el, info, error) => {
+ assert.equal(el, anchor);
+ assert.equal(info.nodeId, '!oops');
+ errorCaptured = error;
+ },
+ updatePopup: () => {
+ updateCalls += 1;
+ },
+ });
+
+ marker.trigger('click', { originalEvent: {} });
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.ok(errorCaptured instanceof Error);
+ assert.equal(errorCaptured.message, 'boom');
+ assert.equal(detailCalls, 0);
+ assert.equal(updateCalls, 2);
+});
+
+test('attachNodeInfoRefreshToMarker skips refresh when identifiers are missing', async () => {
+ const anchor = { id: 'anchor' };
+ const marker = createFakeMarker(anchor);
+ let token = 0;
+ let refreshed = false;
+ let detailsShown = 0;
+
+ attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback: () => ({ shortName: 'Unknown' }),
+ refreshNodeInformation: async () => {
+ refreshed = true;
+ },
+ mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
+ createRequestToken: () => ++token,
+ isTokenCurrent: candidate => candidate === token,
+ showLoading: () => {
+ assert.fail('showLoading should not run without identifiers');
+ },
+ showDetails: (el, info) => {
+ assert.equal(el, anchor);
+ assert.equal(info.shortName, 'Unknown');
+ detailsShown += 1;
+ },
+ });
+
+ marker.trigger('click', { originalEvent: {} });
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.equal(refreshed, false);
+ assert.equal(detailsShown, 1);
+});
+
+test('attachNodeInfoRefreshToMarker honours shouldHandleClick predicate', async () => {
+ const marker = createFakeMarker({ id: 'anchor' });
+ let token = 0;
+ let refreshed = false;
+
+ attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback: () => ({ nodeId: '!skip' }),
+ refreshNodeInformation: async () => {
+ refreshed = true;
+ },
+ mergeOverlayDetails: (primary, fallback) => ({ ...fallback, ...primary }),
+ createRequestToken: () => ++token,
+ isTokenCurrent: candidate => candidate === token,
+ shouldHandleClick: () => false,
+ });
+
+ marker.trigger('click', { originalEvent: {} });
+ await new Promise(resolve => setImmediate(resolve));
+
+ assert.equal(refreshed, false);
+});
+
+test('overlayToPopupNode normalises raw overlay payloads', () => {
+ const overlay = {
+ nodeId: '!foo',
+ nodeNum: 42,
+ shortName: 'Foo',
+ role: 'ROUTER',
+ battery: '77.5',
+ neighbors: [
+ { neighbor_id: '!bar', snr: '12.5', neighbor_short_name: 'Bar' },
+ null,
+ ],
+ };
+
+ const popupNode = overlayToPopupNode(overlay);
+ assert.equal(popupNode.node_id, '!foo');
+ assert.equal(popupNode.node_num, 42);
+ assert.equal(popupNode.short_name, 'Foo');
+ assert.equal(popupNode.role, 'ROUTER');
+ assert.equal(popupNode.battery_level, 77.5);
+ assert.equal(Array.isArray(popupNode.neighbors), true);
+ assert.equal(popupNode.neighbors.length, 1);
+ assert.equal(popupNode.neighbors[0].node.node_id, '!bar');
+ assert.equal(popupNode.neighbors[0].snr, 12.5);
+});
diff --git a/web/public/assets/js/app/__tests__/node-details.test.js b/web/public/assets/js/app/__tests__/node-details.test.js
new file mode 100644
index 0000000..2967fb3
--- /dev/null
+++ b/web/public/assets/js/app/__tests__/node-details.test.js
@@ -0,0 +1,326 @@
+/*
+ * Copyright (C) 2025 l5yth
+ *
+ * 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 { refreshNodeInformation, __testUtils } from '../node-details.js';
+
+const {
+ toTrimmedString,
+ toFiniteNumber,
+ extractString,
+ extractNumber,
+ assignString,
+ assignNumber,
+ mergeNodeFields,
+ mergeTelemetry,
+ mergePosition,
+ parseFallback,
+ normalizeReference,
+} = __testUtils;
+
+function createResponse(status, body) {
+ return {
+ status,
+ ok: status >= 200 && status < 300,
+ json: async () => body,
+ };
+}
+
+test('refreshNodeInformation merges telemetry metrics when the base node lacks them', async () => {
+ const calls = [];
+ const responses = new Map([
+ ['/api/nodes/!test', createResponse(200, {
+ node_id: '!test',
+ short_name: 'TST',
+ battery_level: null,
+ last_heard: 1_000,
+ })],
+ ['/api/telemetry/!test?limit=1', createResponse(200, [{
+ node_id: '!test',
+ battery_level: 73.5,
+ rx_time: 1_200,
+ telemetry_time: 1_180,
+ voltage: 4.1,
+ }])],
+ ['/api/positions/!test?limit=1', createResponse(200, [{
+ node_id: '!test',
+ latitude: 52.5,
+ longitude: 13.4,
+ rx_time: 1_100,
+ }])],
+ ['/api/neighbors/!test?limit=1000', createResponse(200, [{
+ node_id: '!test',
+ neighbor_id: '!peer',
+ snr: 9.5,
+ rx_time: 1_150,
+ }])],
+ ]);
+ const fetchImpl = async (url, options) => {
+ calls.push({ url, options });
+ const response = responses.get(url);
+ if (!response) {
+ return createResponse(404, { error: 'not found' });
+ }
+ return response;
+ };
+
+ const fallback = { shortName: 'fallback', role: 'CLIENT' };
+ const node = await refreshNodeInformation({ nodeId: '!test', fallback }, { fetchImpl });
+
+ assert.equal(node.nodeId, '!test');
+ assert.equal(node.shortName, 'TST');
+ assert.equal(node.battery, 73.5);
+ assert.equal(node.voltage, 4.1);
+ assert.equal(node.role, 'CLIENT');
+ assert.equal(node.lastHeard, 1_200);
+ assert.equal(node.telemetryTime, 1_180);
+ assert.equal(node.latitude, 52.5);
+ assert.equal(node.longitude, 13.4);
+ assert.deepEqual(node.neighbors, [{
+ node_id: '!test',
+ neighbor_id: '!peer',
+ snr: 9.5,
+ rx_time: 1_150,
+ }]);
+ assert.ok(node.rawSources);
+ assert.ok(node.rawSources.node);
+ assert.ok(node.rawSources.telemetry);
+ assert.ok(node.rawSources.position);
+
+ assert.equal(calls.length, 4);
+ calls.forEach(call => {
+ assert.deepEqual(call.options, { cache: 'no-store' });
+ });
+});
+
+test('refreshNodeInformation preserves fallback metrics when telemetry is unavailable', async () => {
+ const responses = new Map([
+ ['/api/nodes/42', createResponse(200, {
+ node_id: '!num',
+ short_name: 'NUM',
+ })],
+ ['/api/telemetry/42?limit=1', createResponse(404, { error: 'not found' })],
+ ['/api/positions/42?limit=1', createResponse(404, { error: 'not found' })],
+ ['/api/neighbors/42?limit=1000', createResponse(404, { error: 'not found' })],
+ ]);
+ const fetchImpl = async (url, options) => {
+ const response = responses.get(url);
+ return response ?? createResponse(404, { error: 'not found' });
+ };
+
+ const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT' };
+ const node = await refreshNodeInformation({ nodeNum: 42, fallback }, { fetchImpl });
+
+ assert.equal(node.nodeId, '!num');
+ assert.equal(node.nodeNum, 42);
+ assert.equal(node.shortName, 'NUM');
+ assert.equal(node.battery, 12.5);
+ assert.equal(node.role, 'CLIENT');
+ assert.equal(Array.isArray(node.neighbors) && node.neighbors.length, 0);
+});
+
+test('refreshNodeInformation requires a node identifier', async () => {
+ await assert.rejects(() => refreshNodeInformation(null), /node identifier/i);
+});
+
+test('refreshNodeInformation handles missing node records by falling back to telemetry data', async () => {
+ const responses = new Map([
+ ['/api/nodes/!missing', createResponse(404, { error: 'not found' })],
+ ['/api/telemetry/!missing?limit=1', createResponse(200, [{
+ node_id: '!missing',
+ node_num: 77,
+ battery_level: 66,
+ rx_time: 2_000,
+ telemetry_time: 1_950,
+ }])],
+ ['/api/positions/!missing?limit=1', createResponse(200, [{
+ node_id: '!missing',
+ latitude: 1.23,
+ longitude: 3.21,
+ altitude: 42,
+ position_time: 1_960,
+ rx_time: 1_970,
+ }])],
+ ['/api/neighbors/!missing?limit=1000', createResponse(200, [null, 'skip', {
+ node_id: '!missing',
+ neighbor_id: '!ally',
+ snr: 8.5,
+ }])],
+ ]);
+
+ const fetchImpl = async url => responses.get(url) ?? createResponse(404, { error: 'not found' });
+
+ const node = await refreshNodeInformation({ nodeId: '!missing' }, { fetchImpl });
+
+ assert.equal(node.nodeId, '!missing');
+ assert.equal(node.nodeNum, 77);
+ assert.equal(node.battery, 66);
+ assert.equal(node.lastHeard, 2_000);
+ assert.equal(node.telemetryTime, 1_950);
+ assert.equal(node.positionTime, 1_960);
+ assert.equal(node.latitude, 1.23);
+ assert.equal(node.longitude, 3.21);
+ assert.equal(node.altitude, 42);
+ assert.equal(node.role, 'CLIENT');
+ assert.deepEqual(node.neighbors, [{
+ node_id: '!missing',
+ neighbor_id: '!ally',
+ snr: 8.5,
+ }]);
+});
+
+test('refreshNodeInformation enforces a fetch implementation', async () => {
+ const originalFetch = globalThis.fetch;
+ // eslint-disable-next-line no-global-assign
+ globalThis.fetch = undefined;
+ try {
+ await assert.rejects(() => refreshNodeInformation('!test', { fetchImpl: null }), /fetch implementation/i);
+ } finally {
+ // eslint-disable-next-line no-global-assign
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test('helper utilities normalise primitive values', () => {
+ assert.equal(toTrimmedString(' hello '), 'hello');
+ assert.equal(toTrimmedString(''), null);
+ assert.equal(toTrimmedString(null), null);
+
+ assert.equal(toFiniteNumber('42.5'), 42.5);
+ assert.equal(toFiniteNumber('bad'), null);
+ assert.equal(toFiniteNumber(Infinity), null);
+
+ assert.equal(extractString({ name: ' Alice ' }, ['missing', 'name']), 'Alice');
+ assert.equal(extractString(null, ['name']), null);
+
+ assert.equal(extractNumber({ value: ' 13 ' }, ['missing', 'value']), 13);
+ assert.equal(extractNumber({}, ['value']), null);
+});
+
+test('assign helpers respect preferExisting semantics', () => {
+ const target = {};
+ assignString(target, 'name', ' primary ');
+ assignString(target, 'name', 'secondary', { preferExisting: true });
+ assignString(target, 'description', '');
+ assignNumber(target, 'count', '25');
+ assignNumber(target, 'count', 13, { preferExisting: true });
+ assignNumber(target, 'ignored', 'oops');
+
+ assert.deepEqual(target, { name: 'primary', count: 25 });
+});
+
+test('merge helpers combine node, telemetry, and position data', () => {
+ const node = {};
+ mergeNodeFields(node, {
+ node_id: '!node',
+ node_num: 55,
+ short_name: 'NODE',
+ battery_level: null,
+ last_heard: 1_000,
+ position_time: 900,
+ });
+
+ node.battery = 50;
+
+ mergeTelemetry(node, {
+ node_id: '!node',
+ battery_level: 75,
+ voltage: 3.8,
+ rx_time: 1_200,
+ rx_iso: '2025-01-01T00:00:00Z',
+ telemetry_time: 1_150,
+ });
+
+ mergePosition(node, {
+ node_id: '!node',
+ latitude: 52.5,
+ longitude: 13.4,
+ altitude: 80,
+ position_time: 1_180,
+ position_time_iso: '2025-01-01T00:19:40Z',
+ rx_time: 1_100,
+ rx_iso: '2025-01-01T00:18:20Z',
+ });
+
+ assert.equal(node.nodeId, '!node');
+ assert.equal(node.nodeNum, 55);
+ assert.equal(node.shortName, 'NODE');
+ assert.equal(node.battery, 50);
+ assert.equal(node.voltage, 3.8);
+ assert.equal(node.lastHeard, 1_200);
+ assert.equal(node.lastSeenIso, '2025-01-01T00:00:00Z');
+ assert.equal(node.telemetryTime, 1_150);
+ assert.equal(node.positionTime, 1_180);
+ assert.equal(node.positionTimeIso, '2025-01-01T00:19:40Z');
+ assert.equal(node.latitude, 52.5);
+ assert.equal(node.longitude, 13.4);
+ assert.equal(node.altitude, 80);
+ assert.ok(node.telemetry);
+ assert.ok(node.position);
+});
+
+test('normalizeReference extracts identifiers and tolerates malformed fallback payloads', () => {
+ const originalWarn = console.warn;
+ const warnings = [];
+ console.warn = (...args) => warnings.push(args);
+
+ try {
+ const parsed = normalizeReference({
+ nodeId: ' ',
+ fallback: '{"node_id":"!parsed","nodeNum":99}',
+ });
+ assert.equal(parsed.nodeId, '!parsed');
+ assert.equal(parsed.nodeNum, 99);
+ assert.ok(parsed.fallback);
+
+ const invalid = normalizeReference({ fallback: '{not json}' });
+ assert.equal(invalid.nodeId, null);
+ assert.equal(invalid.nodeNum, null);
+ assert.equal(invalid.fallback, null);
+
+ const strRef = normalizeReference('!direct');
+ assert.equal(strRef.nodeId, '!direct');
+ assert.equal(strRef.nodeNum, null);
+
+ const numRef = normalizeReference(57);
+ assert.equal(numRef.nodeId, null);
+ assert.equal(numRef.nodeNum, 57);
+
+ const emptyRef = normalizeReference(undefined);
+ assert.equal(emptyRef.nodeId, null);
+ assert.equal(emptyRef.nodeNum, null);
+ assert.equal(emptyRef.fallback, null);
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ assert.ok(warnings.length >= 1);
+});
+
+test('parseFallback duplicates object references and rejects primitives', () => {
+ const fallbackObject = { nodeId: '!object' };
+ const parsedObject = parseFallback(fallbackObject);
+ assert.notEqual(parsedObject, fallbackObject);
+ assert.deepEqual(parsedObject, fallbackObject);
+
+ const parsedString = parseFallback('{"nodeId":"!string"}');
+ assert.ok(parsedString);
+ assert.equal(parsedString.nodeId, '!string');
+ assert.equal(parseFallback('not json'), null);
+ assert.equal(parseFallback(42), null);
+});
diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js
index c4f213b..dd269a5 100644
--- a/web/public/assets/js/app/main.js
+++ b/web/public/assets/js/app/main.js
@@ -16,6 +16,8 @@
import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from './map-bounds.js';
import { createMapAutoFitController } from './map-auto-fit-controller.js';
+import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
+import { refreshNodeInformation } from './node-details.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -100,6 +102,8 @@ export function initializeApp(config) {
let nodesById = new Map();
/** @type {HTMLElement|null} */
let shortInfoAnchor = null;
+ /** @type {number} */
+ let shortInfoRequestToken = 0;
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
@@ -1397,27 +1401,86 @@ export function initializeApp(config) {
document.addEventListener('click', event => {
const shortTarget = event.target.closest('.short-name');
- if (shortTarget && shortTarget.dataset && shortTarget.dataset.nodeInfo) {
+ if (
+ shortTarget &&
+ shortTarget.dataset &&
+ (shortTarget.dataset.nodeInfo || shortTarget.dataset.nodeId || shortTarget.dataset.nodeNum)
+ ) {
event.preventDefault();
event.stopPropagation();
- let info = null;
- try {
- info = JSON.parse(shortTarget.dataset.nodeInfo);
- } catch (err) {
- console.warn('Failed to parse node info payload', err);
+
+ let fallbackInfo = null;
+ if (shortTarget.dataset.nodeInfo) {
+ try {
+ fallbackInfo = JSON.parse(shortTarget.dataset.nodeInfo);
+ } catch (err) {
+ console.warn('Failed to parse node info payload', err);
+ }
}
- if (!info) return;
- if (!info.shortName && shortTarget.textContent) {
- info.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
+ if (!fallbackInfo || typeof fallbackInfo !== 'object') {
+ fallbackInfo = {};
}
- if (!info.role) {
- info.role = 'CLIENT';
+
+ const datasetNodeId = typeof shortTarget.dataset.nodeId === 'string'
+ ? shortTarget.dataset.nodeId.trim()
+ : '';
+ if (datasetNodeId && !fallbackInfo.nodeId && !fallbackInfo.node_id) {
+ fallbackInfo.nodeId = datasetNodeId;
}
+
+ if (fallbackInfo.nodeNum == null && fallbackInfo.node_num == null && shortTarget.dataset.nodeNum != null) {
+ const parsedDatasetNum = Number(shortTarget.dataset.nodeNum);
+ if (Number.isFinite(parsedDatasetNum)) {
+ fallbackInfo.nodeNum = parsedDatasetNum;
+ }
+ }
+
+ if (!fallbackInfo.shortName && shortTarget.textContent) {
+ fallbackInfo.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
+ }
+
+ const fallbackDetails = mergeOverlayDetails(null, fallbackInfo);
+ if (!fallbackDetails.shortName && shortTarget.textContent) {
+ fallbackDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
+ fallbackInfo.shortName = fallbackDetails.shortName;
+ }
+
if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === shortTarget) {
closeShortInfoOverlay();
- } else {
- openShortInfoOverlay(shortTarget, info);
+ return;
}
+
+ const nodeId = typeof fallbackDetails.nodeId === 'string' && fallbackDetails.nodeId.trim().length
+ ? fallbackDetails.nodeId.trim()
+ : '';
+ const nodeNum = Number.isFinite(fallbackDetails.nodeNum) ? fallbackDetails.nodeNum : null;
+
+ if (!nodeId && !nodeNum) {
+ openShortInfoOverlay(shortTarget, fallbackDetails);
+ return;
+ }
+
+ const requestId = ++shortInfoRequestToken;
+ showShortInfoLoading(shortTarget, fallbackDetails);
+
+ refreshNodeInformation({ nodeId: nodeId || undefined, nodeNum: nodeNum ?? undefined, fallback: fallbackInfo })
+ .then(details => {
+ if (requestId !== shortInfoRequestToken) return;
+ const overlayDetails = mergeOverlayDetails(details, fallbackInfo);
+ if (!overlayDetails.shortName && shortTarget.textContent) {
+ overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
+ }
+ openShortInfoOverlay(shortTarget, overlayDetails);
+ })
+ .catch(err => {
+ console.warn('Failed to refresh node information', err);
+ if (requestId !== shortInfoRequestToken) return;
+ const overlayDetails = mergeOverlayDetails(null, fallbackInfo);
+ if (!overlayDetails.shortName && shortTarget.textContent) {
+ overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
+ }
+ openShortInfoOverlay(shortTarget, overlayDetails);
+ });
return;
}
if (event.target.closest('.neighbor-connection-line')) {
@@ -1473,6 +1536,7 @@ export function initializeApp(config) {
if (nodeData && typeof nodeData === 'object') {
const info = {
nodeId: nodeData.node_id ?? nodeData.nodeId ?? '',
+ nodeNum: nodeData.num ?? nodeData.node_num ?? nodeData.nodeNum ?? null,
shortName: short != null ? String(short) : (nodeData.short_name ?? ''),
longName: nodeData.long_name ?? longName ?? '',
role: roleValue,
@@ -1487,7 +1551,16 @@ export function initializeApp(config) {
pressure: nodeData.barometric_pressure ?? nodeData.barometricPressure ?? nodeData.pressure ?? null,
telemetryTime: nodeData.telemetry_time ?? nodeData.telemetryTime ?? null,
};
- infoAttr = ` data-node-info="${escapeHtml(JSON.stringify(info))}"`;
+ const attrParts = [` data-node-info="${escapeHtml(JSON.stringify(info))}"`];
+ const attrNodeIdRaw = info.nodeId != null ? String(info.nodeId).trim() : '';
+ if (attrNodeIdRaw) {
+ attrParts.push(` data-node-id="${escapeHtml(attrNodeIdRaw)}"`);
+ }
+ const attrNodeNum = Number(info.nodeNum);
+ if (Number.isFinite(attrNodeNum)) {
+ attrParts.push(` data-node-num="${escapeHtml(String(attrNodeNum))}"`);
+ }
+ infoAttr = attrParts.join('');
}
if (!short) {
return `? `;
@@ -1581,6 +1654,87 @@ export function initializeApp(config) {
return `${shortHtml}(SNR ${escapeHtml(snrText)})`;
}
+ /**
+ * Build HTML markup describing a node for a Leaflet popup.
+ *
+ * @param {Object} node Map node payload with snake_case keys.
+ * @param {number} nowSec Reference timestamp for relative calculations.
+ * @returns {string} HTML snippet rendered inside the popup.
+ */
+ function buildMapPopupHtml(node, nowSec) {
+ const lines = [];
+ const longName = node && node.long_name ? escapeHtml(String(node.long_name)) : '';
+ if (longName) {
+ lines.push(`${longName}`);
+ }
+
+ const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node);
+ const nodeIdText = node && node.node_id ? `${escapeHtml(String(node.node_id))}` : '';
+ const shortParts = [];
+ if (shortHtml) shortParts.push(shortHtml);
+ if (nodeIdText) shortParts.push(nodeIdText);
+ if (shortParts.length) {
+ lines.push(shortParts.join(' '));
+ }
+
+ const hardwareText = fmtHw(node?.hw_model);
+ if (hardwareText) {
+ lines.push(`Model: ${escapeHtml(hardwareText)}`);
+ }
+
+ const roleValue = node?.role || 'CLIENT';
+ if (roleValue) {
+ lines.push(`Role: ${escapeHtml(roleValue)}`);
+ }
+
+ const batteryParts = [];
+ const batteryText = fmtAlt(node?.battery_level, '%');
+ if (batteryText) batteryParts.push(batteryText);
+ const voltageText = fmtAlt(node?.voltage, 'V');
+ if (voltageText) batteryParts.push(voltageText);
+ if (batteryParts.length) {
+ lines.push(`Battery: ${batteryParts.join(', ')}`);
+ }
+
+ const temperatureText = fmtTemperature(node?.temperature);
+ if (temperatureText) {
+ lines.push(`Temperature: ${temperatureText}`);
+ }
+ const humidityText = fmtHumidity(node?.relative_humidity);
+ if (humidityText) {
+ lines.push(`Humidity: ${humidityText}`);
+ }
+ const pressureText = fmtPressure(node?.barometric_pressure);
+ if (pressureText) {
+ lines.push(`Pressure: ${pressureText}`);
+ }
+
+ const lastHeardNum = Number(node?.last_heard);
+ if (Number.isFinite(lastHeardNum) && lastHeardNum > 0) {
+ lines.push(`Last seen: ${timeAgo(lastHeardNum, nowSec)}`);
+ }
+
+ const uptimeNum = Number(node?.uptime_seconds);
+ if (Number.isFinite(uptimeNum) && uptimeNum > 0) {
+ lines.push(`Uptime: ${timeHum(uptimeNum)}`);
+ }
+
+ const overlayNeighbors = Array.isArray(node?.neighbors) ? node.neighbors : [];
+ const neighborEntries = overlayNeighbors.length
+ ? overlayNeighbors
+ : getNeighborNodesFor(node?.node_id ?? '');
+ if (neighborEntries.length) {
+ const neighborParts = neighborEntries
+ .map(renderNeighborWithSnrHtml)
+ .filter(html => html && html.length);
+ if (neighborParts.length) {
+ lines.push(`Neighbors: ${neighborParts.join(' ')}`);
+ }
+ }
+
+ return lines.join('
');
+ }
+
/**
* Format uptime values for the short-info overlay.
*
@@ -1621,12 +1775,129 @@ export function initializeApp(config) {
lines.push(`${escapeHtml(label)}: ${escapeHtml(String(formatted))}`);
}
+ /**
+ * Transform a node-shaped payload into the overlay data format.
+ *
+ * @param {*} source Arbitrary node data.
+ * @returns {Object} Normalized overlay payload.
+ */
+ function normalizeOverlaySource(source) {
+ if (!source || typeof source !== 'object') return {};
+ const normalized = {};
+ const nodeIdRaw = source.nodeId ?? source.node_id;
+ if (typeof nodeIdRaw === 'string' && nodeIdRaw.trim().length > 0) {
+ normalized.nodeId = nodeIdRaw.trim();
+ }
+ const nodeNumRaw = source.nodeNum ?? source.node_num ?? source.num;
+ const nodeNumParsed = Number(nodeNumRaw);
+ if (Number.isFinite(nodeNumParsed)) {
+ normalized.nodeNum = nodeNumParsed;
+ }
+ const shortRaw = source.shortName ?? source.short_name;
+ if (shortRaw != null && String(shortRaw).trim().length > 0) {
+ normalized.shortName = String(shortRaw).trim();
+ }
+ const longRaw = source.longName ?? source.long_name;
+ if (longRaw != null && String(longRaw).trim().length > 0) {
+ normalized.longName = String(longRaw).trim();
+ }
+ if (source.role && String(source.role).trim().length > 0) {
+ normalized.role = String(source.role).trim();
+ }
+ if (source.hwModel ?? source.hw_model) {
+ normalized.hwModel = source.hwModel ?? source.hw_model;
+ }
+
+ const numericPairs = [
+ ['battery', source.battery ?? source.battery_level],
+ ['voltage', source.voltage],
+ ['uptime', source.uptime ?? source.uptime_seconds],
+ ['channel', source.channel ?? source.channel_utilization],
+ ['airUtil', source.airUtil ?? source.air_util_tx],
+ ['temperature', source.temperature],
+ ['humidity', source.humidity ?? source.relative_humidity],
+ ['pressure', source.pressure ?? source.barometric_pressure],
+ ['telemetryTime', source.telemetryTime ?? source.telemetry_time],
+ ['lastHeard', source.lastHeard ?? source.last_heard],
+ ['latitude', source.latitude],
+ ['longitude', source.longitude],
+ ['altitude', source.altitude],
+ ['positionTime', source.positionTime ?? source.position_time],
+ ];
+ for (const [key, value] of numericPairs) {
+ if (value == null || value === '') continue;
+ const num = Number(value);
+ if (Number.isFinite(num)) {
+ normalized[key] = num;
+ }
+ }
+
+ const lastSeenRaw = source.lastSeenIso ?? source.last_seen_iso;
+ if (typeof lastSeenRaw === 'string' && lastSeenRaw.trim().length > 0) {
+ normalized.lastSeenIso = lastSeenRaw.trim();
+ }
+ const positionIsoRaw = source.positionTimeIso ?? source.position_time_iso;
+ if (typeof positionIsoRaw === 'string' && positionIsoRaw.trim().length > 0) {
+ normalized.positionTimeIso = positionIsoRaw.trim();
+ }
+
+ if (Array.isArray(source.neighbors)) {
+ const overlayNeighbors = overlayToPopupNode({ neighbors: source.neighbors }).neighbors;
+ if (overlayNeighbors.length) {
+ normalized.neighbors = overlayNeighbors;
+ }
+ }
+
+ return normalized;
+ }
+
+ /**
+ * Combine primary and fallback node information into an overlay payload.
+ *
+ * @param {*} primary Primary node details (e.g. fetched from the API).
+ * @param {*} fallback Fallback node details rendered with the page.
+ * @returns {Object} Overlay payload ready for rendering.
+ */
+ function mergeOverlayDetails(primary, fallback) {
+ const fallbackNormalized = normalizeOverlaySource(fallback);
+ const primaryNormalized = normalizeOverlaySource(primary);
+ const merged = { ...fallbackNormalized, ...primaryNormalized };
+ const neighborList = primaryNormalized.neighbors ?? fallbackNormalized.neighbors;
+ if (neighborList) {
+ merged.neighbors = neighborList;
+ }
+ if (!merged.role || merged.role === '') {
+ merged.role = 'CLIENT';
+ }
+ return merged;
+ }
+
+ /**
+ * Display a temporary loading state while node details are fetched.
+ *
+ * @param {HTMLElement} target Anchor element associated with the overlay.
+ * @param {Object} [info] Optional fallback information describing the node.
+ * @returns {void}
+ */
+ function showShortInfoLoading(target, info) {
+ if (!shortInfoOverlay || !shortInfoContent) return;
+ const normalized = normalizeOverlaySource(info || {});
+ const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
+ const headingHtml = heading ? `${escapeHtml(heading)}
` : '';
+ shortInfoContent.innerHTML = `${headingHtml}Loading…`;
+ shortInfoAnchor = target;
+ shortInfoOverlay.hidden = false;
+ shortInfoOverlay.style.visibility = 'hidden';
+ requestAnimationFrame(positionShortInfoOverlay);
+ }
+
/**
* Hide the short-info overlay used for inline node details.
*
* @returns {void}
*/
function closeShortInfoOverlay() {
+ shortInfoRequestToken += 1;
if (!shortInfoOverlay) return;
shortInfoOverlay.hidden = true;
shortInfoOverlay.style.visibility = 'visible';
@@ -1668,29 +1939,35 @@ export function initializeApp(config) {
*/
function openShortInfoOverlay(target, info) {
if (!shortInfoOverlay || !shortInfoContent || !info) return;
+ const overlayInfo = normalizeOverlaySource(info);
+ if (!overlayInfo.role || overlayInfo.role === '') {
+ overlayInfo.role = 'CLIENT';
+ }
const lines = [];
- const longNameValue = shortInfoValueOrDash(info.longName ?? '');
+ const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? '');
if (longNameValue !== '—') {
lines.push(`${escapeHtml(longNameValue)}`);
}
const shortParts = [];
- const shortHtml = renderShortHtml(info.shortName, info.role, info.longName);
+ const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
if (shortHtml) {
shortParts.push(shortHtml);
}
- const nodeIdValue = shortInfoValueOrDash(info.nodeId ?? '');
+ const nodeIdValue = shortInfoValueOrDash(overlayInfo.nodeId ?? '');
if (nodeIdValue !== '—') {
shortParts.push(`${escapeHtml(nodeIdValue)}`);
}
if (shortParts.length) {
lines.push(shortParts.join(' '));
}
- const roleValue = shortInfoValueOrDash(info.role || 'CLIENT');
+ const roleValue = shortInfoValueOrDash(overlayInfo.role || 'CLIENT');
if (roleValue !== '—') {
lines.push(`Role: ${escapeHtml(roleValue)}`);
}
let neighborLineHtml = '';
- const neighborEntries = getNeighborNodesFor(info.nodeId);
+ const neighborEntries = Array.isArray(overlayInfo.neighbors) && overlayInfo.neighbors.some(entry => entry && entry.node)
+ ? overlayInfo.neighbors
+ : getNeighborNodesFor(overlayInfo.nodeId);
if (neighborEntries.length) {
const neighborParts = neighborEntries
.map(renderNeighborWithSnrHtml)
@@ -1699,18 +1976,18 @@ export function initializeApp(config) {
neighborLineHtml = `Neighbors: ${neighborParts.join(' ')}`;
}
}
- const modelValue = fmtHw(info.hwModel);
+ const modelValue = fmtHw(overlayInfo.hwModel);
if (modelValue) {
lines.push(`Model: ${escapeHtml(modelValue)}`);
}
- appendTelemetryLine(lines, 'Battery', info.battery, value => fmtAlt(value, '%'));
- appendTelemetryLine(lines, 'Voltage', info.voltage, value => fmtAlt(value, 'V'));
- appendTelemetryLine(lines, 'Uptime', info.uptime, formatShortInfoUptime);
- appendTelemetryLine(lines, 'Channel Util', info.channel, fmtTx);
- appendTelemetryLine(lines, 'Air Util Tx', info.airUtil, fmtTx);
- appendTelemetryLine(lines, 'Temperature', info.temperature, fmtTemperature);
- appendTelemetryLine(lines, 'Humidity', info.humidity, fmtHumidity);
- appendTelemetryLine(lines, 'Pressure', info.pressure, fmtPressure);
+ appendTelemetryLine(lines, 'Battery', overlayInfo.battery, value => fmtAlt(value, '%'));
+ appendTelemetryLine(lines, 'Voltage', overlayInfo.voltage, value => fmtAlt(value, 'V'));
+ appendTelemetryLine(lines, 'Uptime', overlayInfo.uptime, formatShortInfoUptime);
+ appendTelemetryLine(lines, 'Channel Util', overlayInfo.channel, fmtTx);
+ appendTelemetryLine(lines, 'Air Util Tx', overlayInfo.airUtil, fmtTx);
+ appendTelemetryLine(lines, 'Temperature', overlayInfo.temperature, fmtTemperature);
+ appendTelemetryLine(lines, 'Humidity', overlayInfo.humidity, fmtHumidity);
+ appendTelemetryLine(lines, 'Pressure', overlayInfo.pressure, fmtPressure);
if (neighborLineHtml) {
lines.push(neighborLineHtml);
}
@@ -2554,51 +2831,65 @@ export function initializeApp(config) {
fillOpacity: 0.7,
opacity: 0.7
});
- const lines = [];
- lines.push(`${n.long_name || ''}`);
- lines.push(`${renderShortHtml(n.short_name, n.role, n.long_name, n)} ${n.node_id || ''}`);
- if (n.hw_model) {
- lines.push(`Model: ${fmtHw(n.hw_model)}`);
- }
- lines.push(`Role: ${n.role || 'CLIENT'}`);
- const batteryParts = [];
- const batteryText = fmtAlt(n.battery_level, "%");
- if (batteryText) batteryParts.push(batteryText);
- const voltageText = fmtAlt(n.voltage, "V");
- if (voltageText) batteryParts.push(voltageText);
- if (batteryParts.length) {
- lines.push(`Battery: ${batteryParts.join(', ')}`);
- }
- const tempText = fmtTemperature(n.temperature);
- if (tempText) {
- lines.push(`Temperature: ${tempText}`);
- }
- const humidityText = fmtHumidity(n.relative_humidity);
- if (humidityText) {
- lines.push(`Humidity: ${humidityText}`);
- }
- const pressureText = fmtPressure(n.barometric_pressure);
- if (pressureText) {
- lines.push(`Pressure: ${pressureText}`);
- }
- if (n.last_heard) {
- lines.push(`Last seen: ${timeAgo(n.last_heard, nowSec)}`);
- }
- if (n.uptime_seconds) {
- lines.push(`Uptime: ${timeHum(n.uptime_seconds)}`);
- }
- const mapNeighborEntries = getNeighborNodesFor(n.node_id ?? n.nodeId ?? '');
- if (mapNeighborEntries.length) {
- const neighborParts = mapNeighborEntries
- .map(renderNeighborWithSnrHtml)
- .filter(html => html && html.length);
- if (neighborParts.length) {
- lines.push(`Neighbors: ${neighborParts.join(' ')}`);
- }
- }
- marker.bindPopup(lines.join('
'));
+
+ const fallbackOverlayProvider = () => mergeOverlayDetails(null, n);
+ const initialOverlay = fallbackOverlayProvider();
+ const initialPopupHtml = buildMapPopupHtml(overlayToPopupNode(initialOverlay), nowSec);
+
+ marker.bindPopup(initialPopupHtml);
marker.addTo(markersLayer);
pts.push([lat, lon]);
+
+ const updateMarkerPopup = overlayDetails => {
+ const popupNode = overlayToPopupNode(overlayDetails);
+ const html = buildMapPopupHtml(popupNode, Math.floor(Date.now() / 1000));
+ if (typeof marker.setPopupContent === 'function') {
+ marker.setPopupContent(html);
+ return;
+ }
+ if (typeof marker.getPopup === 'function') {
+ const popup = marker.getPopup();
+ if (popup && typeof popup.setContent === 'function') {
+ popup.setContent(html);
+ return;
+ }
+ }
+ marker.bindPopup(html);
+ };
+
+ attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback: fallbackOverlayProvider,
+ refreshNodeInformation,
+ mergeOverlayDetails,
+ createRequestToken: () => ++shortInfoRequestToken,
+ isTokenCurrent: token => token === shortInfoRequestToken,
+ showLoading: (anchor, info) => {
+ if (anchor) {
+ showShortInfoLoading(anchor, info);
+ }
+ },
+ showDetails: (anchor, info) => {
+ if (anchor) {
+ openShortInfoOverlay(anchor, info);
+ }
+ },
+ showError: (anchor, info, error) => {
+ console.warn('Failed to refresh node information for map marker', error);
+ if (anchor) {
+ openShortInfoOverlay(anchor, info);
+ }
+ },
+ updatePopup: updateMarkerPopup,
+ shouldHandleClick: anchor => {
+ if (!anchor) return true;
+ if (shortInfoOverlay && !shortInfoOverlay.hidden && shortInfoAnchor === anchor) {
+ closeShortInfoOverlay();
+ return false;
+ }
+ return true;
+ },
+ });
}
if (pts.length && fitBoundsEl && fitBoundsEl.checked) {
const bounds = computeBoundsForPoints(pts, {
diff --git a/web/public/assets/js/app/map-marker-node-info.js b/web/public/assets/js/app/map-marker-node-info.js
new file mode 100644
index 0000000..9bfc381
--- /dev/null
+++ b/web/public/assets/js/app/map-marker-node-info.js
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2025 l5yth
+ *
+ * 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.
+ */
+
+/**
+ * Determine whether the provided value behaves like a plain object.
+ *
+ * @param {*} value Candidate value.
+ * @returns {boolean} True when ``value`` is a non-null object.
+ */
+function isObject(value) {
+ return value != null && typeof value === 'object';
+}
+
+/**
+ * Convert a value to a trimmed string when possible.
+ *
+ * @param {*} value Input value.
+ * @returns {string|null} Trimmed string or ``null`` when blank.
+ */
+function toTrimmedString(value) {
+ if (value == null) return null;
+ const str = String(value).trim();
+ return str.length === 0 ? null : str;
+}
+
+/**
+ * Attempt to coerce the provided value into a finite number.
+ *
+ * @param {*} value Raw value.
+ * @returns {number|null} Finite number or ``null`` when coercion fails.
+ */
+function toFiniteNumber(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;
+}
+
+/**
+ * Normalise a neighbour entry so that downstream consumers can display it.
+ *
+ * @param {*} entry Raw neighbour entry.
+ * @returns {Object|null} Normalised neighbour reference or ``null`` when invalid.
+ */
+function normaliseNeighbor(entry) {
+ if (!isObject(entry)) return null;
+ const neighborId = toTrimmedString(entry.neighbor_id ?? entry.neighborId ?? entry.nodeId ?? entry.node_id);
+ if (!neighborId) return null;
+ const neighborShort = toTrimmedString(entry.neighbor_short_name ?? entry.neighborShortName ?? entry.short_name ?? entry.shortName);
+ const neighborLong = toTrimmedString(entry.neighbor_long_name ?? entry.neighborLongName ?? entry.long_name ?? entry.longName);
+ const neighborRole = toTrimmedString(entry.neighbor_role ?? entry.neighborRole ?? entry.role) || 'CLIENT';
+ const node = {
+ node_id: neighborId,
+ short_name: neighborShort ?? '',
+ long_name: neighborLong ?? '',
+ role: neighborRole,
+ };
+ const snr = toFiniteNumber(entry.snr);
+ const rxTime = toFiniteNumber(entry.rx_time ?? entry.rxTime);
+ const result = { node };
+ if (snr != null) {
+ result.snr = snr;
+ }
+ if (rxTime != null) {
+ result.rxTime = rxTime;
+ result.rx_time = rxTime;
+ }
+ return result;
+}
+
+/**
+ * Convert overlay node details into a map friendly payload.
+ *
+ * @param {*} source Raw overlay details.
+ * @returns {Object} Map node payload containing snake_case keys.
+ */
+export function overlayToPopupNode(source) {
+ if (!isObject(source)) {
+ return {
+ node_id: '',
+ node_num: null,
+ short_name: '',
+ long_name: '',
+ role: 'CLIENT',
+ neighbors: [],
+ };
+ }
+
+ const nodeId = toTrimmedString(source.nodeId ?? source.node_id ?? source.id) ?? '';
+ const nodeNum = toFiniteNumber(source.nodeNum ?? source.node_num ?? source.num);
+ const role = toTrimmedString(source.role) || 'CLIENT';
+ const neighbours = Array.isArray(source.neighbors)
+ ? source.neighbors.map(normaliseNeighbor).filter(Boolean)
+ : [];
+
+ const payload = {
+ node_id: nodeId,
+ node_num: nodeNum,
+ short_name: toTrimmedString(source.shortName ?? source.short_name ?? source.name) ?? '',
+ long_name: toTrimmedString(source.longName ?? source.long_name ?? source.fullName ?? '') ?? '',
+ role,
+ hw_model: toTrimmedString(source.hwModel ?? source.hw_model ?? source.hardware) ?? '',
+ battery_level: toFiniteNumber(source.battery ?? source.battery_level),
+ voltage: toFiniteNumber(source.voltage),
+ uptime_seconds: toFiniteNumber(source.uptime ?? source.uptime_seconds),
+ channel_utilization: toFiniteNumber(source.channel ?? source.channel_utilization),
+ air_util_tx: toFiniteNumber(source.airUtil ?? source.air_util_tx),
+ temperature: toFiniteNumber(source.temperature),
+ relative_humidity: toFiniteNumber(source.humidity ?? source.relative_humidity),
+ barometric_pressure: toFiniteNumber(source.pressure ?? source.barometric_pressure),
+ telemetry_time: toFiniteNumber(source.telemetryTime ?? source.telemetry_time),
+ last_heard: toFiniteNumber(source.lastHeard ?? source.last_heard),
+ position_time: toFiniteNumber(source.positionTime ?? source.position_time),
+ latitude: toFiniteNumber(source.latitude),
+ longitude: toFiniteNumber(source.longitude),
+ altitude: toFiniteNumber(source.altitude),
+ neighbors: neighbours,
+ };
+
+ if (!payload.long_name && payload.short_name) {
+ payload.long_name = payload.short_name;
+ }
+
+ return payload;
+}
+
+/**
+ * Attach an asynchronous refresh handler to a Leaflet marker so that
+ * up-to-date node information is fetched whenever the marker is clicked.
+ *
+ * @param {Object} options Behaviour configuration.
+ * @param {Object} options.marker Leaflet marker instance supporting ``on``.
+ * @param {Function} options.getOverlayFallback Returns the fallback overlay payload.
+ * @param {Function} options.refreshNodeInformation Async function fetching node details.
+ * @param {Function} options.mergeOverlayDetails Merge function combining fetched and fallback details.
+ * @param {Function} options.createRequestToken Generates a token for cancellation tracking.
+ * @param {Function} options.isTokenCurrent Tests whether a request token is still current.
+ * @param {Function} [options.showLoading] Callback invoked before refreshing.
+ * @param {Function} [options.showDetails] Callback invoked with merged overlay details.
+ * @param {Function} [options.showError] Callback invoked when refreshing fails.
+ * @param {Function} [options.updatePopup] Callback updating the marker popup contents.
+ * @param {Function} [options.shouldHandleClick] Predicate that decides whether the click should trigger a refresh.
+ * @returns {void}
+ */
+export function attachNodeInfoRefreshToMarker({
+ marker,
+ getOverlayFallback,
+ refreshNodeInformation,
+ mergeOverlayDetails,
+ createRequestToken,
+ isTokenCurrent,
+ showLoading,
+ showDetails,
+ showError,
+ updatePopup,
+ shouldHandleClick,
+}) {
+ if (!isObject(marker) || typeof marker.on !== 'function') {
+ throw new TypeError('A Leaflet marker with an on() method is required');
+ }
+ if (typeof refreshNodeInformation !== 'function') {
+ throw new TypeError('A refreshNodeInformation function must be provided');
+ }
+ if (typeof mergeOverlayDetails !== 'function') {
+ throw new TypeError('A mergeOverlayDetails function must be provided');
+ }
+ if (typeof createRequestToken !== 'function' || typeof isTokenCurrent !== 'function') {
+ throw new TypeError('Token management callbacks must be provided');
+ }
+
+ marker.on('click', event => {
+ if (event && event.originalEvent) {
+ const original = event.originalEvent;
+ if (typeof original.preventDefault === 'function') {
+ original.preventDefault();
+ }
+ if (typeof original.stopPropagation === 'function') {
+ original.stopPropagation();
+ }
+ }
+
+ const fallbackOverlay = typeof getOverlayFallback === 'function' ? getOverlayFallback() : null;
+ const anchor = typeof marker.getElement === 'function' ? marker.getElement() : null;
+
+ if (!isObject(fallbackOverlay)) {
+ if (anchor && typeof showDetails === 'function') {
+ showDetails(anchor, {});
+ }
+ return;
+ }
+
+ if (typeof shouldHandleClick === 'function' && !shouldHandleClick(anchor, fallbackOverlay)) {
+ return;
+ }
+
+ if (typeof updatePopup === 'function') {
+ updatePopup(fallbackOverlay);
+ }
+
+ const nodeId = toTrimmedString(fallbackOverlay.nodeId ?? fallbackOverlay.node_id ?? fallbackOverlay.id);
+ const nodeNum = toFiniteNumber(fallbackOverlay.nodeNum ?? fallbackOverlay.node_num ?? fallbackOverlay.num);
+
+ if (!nodeId && nodeNum == null) {
+ if (anchor && typeof showDetails === 'function') {
+ showDetails(anchor, fallbackOverlay);
+ }
+ return;
+ }
+
+ const requestToken = createRequestToken();
+
+ if (anchor && typeof showLoading === 'function') {
+ showLoading(anchor, fallbackOverlay);
+ }
+
+ const reference = { fallback: fallbackOverlay };
+ if (nodeId) reference.nodeId = nodeId;
+ if (nodeNum != null) reference.nodeNum = nodeNum;
+
+ let refreshPromise;
+ try {
+ refreshPromise = Promise.resolve(refreshNodeInformation(reference));
+ } catch (error) {
+ if (isTokenCurrent(requestToken)) {
+ if (anchor && typeof showError === 'function') {
+ showError(anchor, fallbackOverlay, error);
+ }
+ }
+ return;
+ }
+
+ refreshPromise
+ .then(details => {
+ if (!isTokenCurrent(requestToken)) {
+ return;
+ }
+ const merged = mergeOverlayDetails(details, fallbackOverlay);
+ if (typeof updatePopup === 'function') {
+ updatePopup(merged);
+ }
+ if (anchor && typeof showDetails === 'function') {
+ showDetails(anchor, merged);
+ }
+ })
+ .catch(error => {
+ if (!isTokenCurrent(requestToken)) {
+ return;
+ }
+ if (typeof updatePopup === 'function') {
+ updatePopup(fallbackOverlay);
+ }
+ if (anchor && typeof showError === 'function') {
+ showError(anchor, fallbackOverlay, error);
+ }
+ });
+ });
+}
+
+export const __testUtils = {
+ isObject,
+ toTrimmedString,
+ toFiniteNumber,
+ normaliseNeighbor,
+};
diff --git a/web/public/assets/js/app/node-details.js b/web/public/assets/js/app/node-details.js
new file mode 100644
index 0000000..2cb4503
--- /dev/null
+++ b/web/public/assets/js/app/node-details.js
@@ -0,0 +1,416 @@
+/*
+ * Copyright (C) 2025 l5yth
+ *
+ * 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.
+ */
+
+const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
+const TELEMETRY_LIMIT = 1;
+const POSITION_LIMIT = 1;
+const NEIGHBOR_LIMIT = 1000;
+
+/**
+ * Determine whether the supplied value behaves like a plain object.
+ *
+ * @param {*} value Candidate value.
+ * @returns {boolean} True when ``value`` is an object instance.
+ */
+function isObject(value) {
+ return value != null && typeof value === 'object';
+}
+
+/**
+ * Convert a candidate value into a trimmed string representation.
+ *
+ * @param {*} value Raw value from an API payload.
+ * @returns {string|null} Trimmed string or ``null`` when blank.
+ */
+function toTrimmedString(value) {
+ if (value == null) return null;
+ const str = String(value).trim();
+ return str.length === 0 ? null : str;
+}
+
+/**
+ * Coerce a candidate value to a finite number when possible.
+ *
+ * @param {*} value Raw value from an API payload.
+ * @returns {number|null} Finite number or ``null`` when coercion fails.
+ */
+function toFiniteNumber(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;
+}
+
+/**
+ * Extract the first non-empty string associated with one of the provided keys.
+ *
+ * @param {Object} record Source record inspected for values.
+ * @param {Array} keys Candidate property names.
+ * @returns {string|null} First non-empty string or ``null``.
+ */
+function extractString(record, keys) {
+ if (!isObject(record)) return null;
+ for (const key of keys) {
+ if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
+ const value = toTrimmedString(record[key]);
+ if (value != null) return value;
+ }
+ return null;
+}
+
+/**
+ * Extract the first finite number associated with the provided keys.
+ *
+ * @param {Object} record Source record inspected for values.
+ * @param {Array} keys Candidate property names.
+ * @returns {number|null} First finite number or ``null``.
+ */
+function extractNumber(record, keys) {
+ if (!isObject(record)) return null;
+ for (const key of keys) {
+ if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
+ const value = toFiniteNumber(record[key]);
+ if (value != null) return value;
+ }
+ return null;
+}
+
+/**
+ * Assign a string property when the supplied value is present.
+ *
+ * @param {Object} target Destination object mutated with the value.
+ * @param {string} key Property name to assign.
+ * @param {*} value Raw value to assign.
+ * @param {Object} [options] Behaviour modifiers.
+ * @param {boolean} [options.preferExisting=false] When true, only assign when the target lacks a value.
+ * @returns {void}
+ */
+function assignString(target, key, value, { preferExisting = false } = {}) {
+ const stringValue = toTrimmedString(value);
+ if (stringValue == null) return;
+ if (preferExisting) {
+ const existing = toTrimmedString(target[key]);
+ if (existing != null) return;
+ }
+ target[key] = stringValue;
+}
+
+/**
+ * Assign a numeric property when the supplied value parses successfully.
+ *
+ * @param {Object} target Destination object mutated with the value.
+ * @param {string} key Property name to assign.
+ * @param {*} value Raw value to assign.
+ * @param {Object} [options] Behaviour modifiers.
+ * @param {boolean} [options.preferExisting=false] When true, only assign when the target lacks a value.
+ * @returns {void}
+ */
+function assignNumber(target, key, value, { preferExisting = false } = {}) {
+ const numericValue = toFiniteNumber(value);
+ if (numericValue == null) return;
+ if (preferExisting) {
+ const existing = toFiniteNumber(target[key]);
+ if (existing != null) return;
+ }
+ target[key] = numericValue;
+}
+
+/**
+ * Merge base node fields from an arbitrary record into the aggregate node object.
+ *
+ * @param {Object} target Mutable aggregate node reference.
+ * @param {Object} record Source record providing base attributes.
+ * @returns {void}
+ */
+function mergeNodeFields(target, record) {
+ if (!isObject(record)) return;
+ assignString(target, 'nodeId', extractString(record, ['nodeId', 'node_id']));
+ assignNumber(target, 'nodeNum', extractNumber(record, ['nodeNum', 'node_num', 'num']));
+ assignString(target, 'shortName', extractString(record, ['shortName', 'short_name']));
+ assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
+ assignString(target, 'role', extractString(record, ['role']));
+ assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
+ assignNumber(target, 'snr', extractNumber(record, ['snr']));
+ assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
+ assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
+ assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds']));
+ assignNumber(target, 'channel', extractNumber(record, ['channel', 'channel_utilization', 'channelUtilization']));
+ assignNumber(target, 'airUtil', extractNumber(record, ['airUtil', 'air_util_tx', 'airUtilTx']));
+ assignNumber(target, 'temperature', extractNumber(record, ['temperature']));
+ assignNumber(target, 'humidity', extractNumber(record, ['humidity', 'relative_humidity', 'relativeHumidity']));
+ assignNumber(target, 'pressure', extractNumber(record, ['pressure', 'barometric_pressure', 'barometricPressure']));
+ assignNumber(target, 'lastHeard', extractNumber(record, ['lastHeard', 'last_heard']));
+ assignString(target, 'lastSeenIso', extractString(record, ['lastSeenIso', 'last_seen_iso']));
+ assignNumber(target, 'positionTime', extractNumber(record, ['position_time', 'positionTime']));
+ assignString(target, 'positionTimeIso', extractString(record, ['position_time_iso', 'positionTimeIso']));
+ assignNumber(target, 'telemetryTime', extractNumber(record, ['telemetry_time', 'telemetryTime']));
+ assignNumber(target, 'latitude', extractNumber(record, ['latitude']));
+ assignNumber(target, 'longitude', extractNumber(record, ['longitude']));
+ assignNumber(target, 'altitude', extractNumber(record, ['altitude']));
+}
+
+/**
+ * Merge telemetry metrics into the aggregate node object when missing.
+ *
+ * @param {Object} target Mutable aggregate node reference.
+ * @param {Object} telemetry Telemetry record returned by the API.
+ * @returns {void}
+ */
+function mergeTelemetry(target, telemetry) {
+ if (!isObject(telemetry)) return;
+ target.telemetry = telemetry;
+ assignString(target, 'nodeId', extractString(telemetry, ['node_id', 'nodeId']), { preferExisting: true });
+ assignNumber(target, 'nodeNum', extractNumber(telemetry, ['node_num', 'nodeNum']), { preferExisting: true });
+ assignNumber(target, 'battery', extractNumber(telemetry, ['battery_level', 'batteryLevel']), { preferExisting: true });
+ assignNumber(target, 'voltage', extractNumber(telemetry, ['voltage']), { preferExisting: true });
+ assignNumber(target, 'uptime', extractNumber(telemetry, ['uptime_seconds', 'uptimeSeconds']), { preferExisting: true });
+ assignNumber(target, 'channel', extractNumber(telemetry, ['channel', 'channel_utilization', 'channelUtilization']), { preferExisting: true });
+ assignNumber(target, 'airUtil', extractNumber(telemetry, ['air_util_tx', 'airUtilTx', 'airUtil']), { preferExisting: true });
+ assignNumber(target, 'temperature', extractNumber(telemetry, ['temperature']), { preferExisting: true });
+ assignNumber(target, 'humidity', extractNumber(telemetry, ['relative_humidity', 'relativeHumidity', 'humidity']), { preferExisting: true });
+ assignNumber(target, 'pressure', extractNumber(telemetry, ['barometric_pressure', 'barometricPressure', 'pressure']), { preferExisting: true });
+
+ const telemetryTime = extractNumber(telemetry, ['telemetry_time', 'telemetryTime']);
+ if (telemetryTime != null) {
+ const existingTelemetryTime = toFiniteNumber(target.telemetryTime);
+ if (existingTelemetryTime == null || telemetryTime > existingTelemetryTime) {
+ target.telemetryTime = telemetryTime;
+ }
+ }
+
+ const rxTime = extractNumber(telemetry, ['rx_time', 'rxTime']);
+ if (rxTime != null) {
+ const existingLastHeard = toFiniteNumber(target.lastHeard);
+ if (existingLastHeard == null || rxTime > existingLastHeard) {
+ target.lastHeard = rxTime;
+ assignString(target, 'lastSeenIso', extractString(telemetry, ['rx_iso', 'rxIso']));
+ } else {
+ assignString(target, 'lastSeenIso', extractString(telemetry, ['rx_iso', 'rxIso']), { preferExisting: true });
+ }
+ }
+}
+
+/**
+ * Merge position data into the aggregate node object when missing.
+ *
+ * @param {Object} target Mutable aggregate node reference.
+ * @param {Object} position Position record returned by the API.
+ * @returns {void}
+ */
+function mergePosition(target, position) {
+ if (!isObject(position)) return;
+ target.position = position;
+ assignString(target, 'nodeId', extractString(position, ['node_id', 'nodeId']), { preferExisting: true });
+ assignNumber(target, 'nodeNum', extractNumber(position, ['node_num', 'nodeNum']), { preferExisting: true });
+ assignNumber(target, 'latitude', extractNumber(position, ['latitude']), { preferExisting: true });
+ assignNumber(target, 'longitude', extractNumber(position, ['longitude']), { preferExisting: true });
+ assignNumber(target, 'altitude', extractNumber(position, ['altitude']), { preferExisting: true });
+
+ const positionTime = extractNumber(position, ['position_time', 'positionTime']);
+ if (positionTime != null) {
+ const existingPositionTime = toFiniteNumber(target.positionTime);
+ if (existingPositionTime == null || positionTime > existingPositionTime) {
+ target.positionTime = positionTime;
+ assignString(target, 'positionTimeIso', extractString(position, ['position_time_iso', 'positionTimeIso']));
+ } else {
+ assignString(target, 'positionTimeIso', extractString(position, ['position_time_iso', 'positionTimeIso']), { preferExisting: true });
+ }
+ }
+
+ const rxTime = extractNumber(position, ['rx_time', 'rxTime']);
+ if (rxTime != null) {
+ const existingLastHeard = toFiniteNumber(target.lastHeard);
+ if (existingLastHeard == null || rxTime > existingLastHeard) {
+ target.lastHeard = rxTime;
+ assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']));
+ } else {
+ assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']), { preferExisting: true });
+ }
+ }
+}
+
+/**
+ * Safely parse a fallback payload used as an initial node reference.
+ *
+ * @param {*} fallback User-provided fallback data.
+ * @returns {Object|null} Parsed fallback object or ``null``.
+ */
+function parseFallback(fallback) {
+ if (isObject(fallback)) return { ...fallback };
+ if (typeof fallback === 'string') {
+ try {
+ const parsed = JSON.parse(fallback);
+ return isObject(parsed) ? parsed : null;
+ } catch (error) {
+ console.warn('Failed to parse node fallback payload', error);
+ return null;
+ }
+ }
+ return null;
+}
+
+/**
+ * Normalise a node reference into a canonical structure used by the fetcher.
+ *
+ * @param {*} reference Raw reference passed to {@link refreshNodeInformation}.
+ * @returns {{nodeId: (string|null), nodeNum: (number|null), fallback: (Object|null)}} Normalised reference data.
+ */
+function normalizeReference(reference) {
+ if (reference == null) {
+ return { nodeId: null, nodeNum: null, fallback: null };
+ }
+ if (typeof reference === 'string') {
+ return { nodeId: toTrimmedString(reference), nodeNum: null, fallback: null };
+ }
+ if (typeof reference === 'number') {
+ const nodeNum = toFiniteNumber(reference);
+ return { nodeId: null, nodeNum, fallback: null };
+ }
+
+ if (!isObject(reference)) {
+ return { nodeId: null, nodeNum: null, fallback: null };
+ }
+
+ const fallback = parseFallback(reference.fallback ?? reference.nodeInfo ?? null);
+ let nodeId = toTrimmedString(reference.nodeId ?? reference.node_id ?? null);
+ if (nodeId == null) {
+ nodeId = toTrimmedString(fallback?.nodeId ?? fallback?.node_id ?? null);
+ }
+ let nodeNum = reference.nodeNum ?? reference.node_num ?? null;
+ if (nodeNum == null) {
+ nodeNum = fallback?.nodeNum ?? fallback?.node_num ?? null;
+ }
+ nodeNum = toFiniteNumber(nodeNum);
+
+ return { nodeId, nodeNum, fallback };
+}
+
+/**
+ * Retrieve and merge node, telemetry, position, and neighbor information.
+ *
+ * @param {*} reference Node identifier string/number or an object containing ``nodeId``/``nodeNum``.
+ * @param {{fetchImpl?: Function}} [options] Optional overrides such as a custom ``fetch`` implementation.
+ * @returns {Promise