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
This commit is contained in:
l5y
2025-10-13 10:54:47 +02:00
committed by GitHub
parent 009965f2fb
commit d66b09ddee
7 changed files with 1844 additions and 74 deletions
+51 -3
View File
@@ -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
@@ -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);
});
@@ -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);
});
+362 -71
View File
@@ -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 `<span class="short-name" style="background:#ccc"${titleAttr}${infoAttr}>?&nbsp;&nbsp;&nbsp;</span>`;
@@ -1581,6 +1654,87 @@ export function initializeApp(config) {
return `${shortHtml}<span class="neighbor-snr">(SNR ${escapeHtml(snrText)})</span>`;
}
/**
* 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(`<b>${longName}</b>`);
}
const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node);
const nodeIdText = node && node.node_id ? `<span class="mono">${escapeHtml(String(node.node_id))}</span>` : '';
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('<br/>');
}
/**
* 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 ? `<strong>${escapeHtml(heading)}</strong><br/>` : '';
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(`<strong>${escapeHtml(longNameValue)}</strong>`);
}
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(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
}
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(`<b>${n.long_name || ''}</b>`);
lines.push(`${renderShortHtml(n.short_name, n.role, n.long_name, n)} <span class="mono">${n.node_id || ''}</span>`);
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('<br/>'));
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, {
@@ -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,
};
+416
View File
@@ -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<string>} 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<string>} 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<Object>} Normalised node payload enriched with telemetry, position, and neighbor data.
*/
export async function refreshNodeInformation(reference, options = {}) {
const normalized = normalizeReference(reference);
const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : globalThis.fetch;
if (typeof fetchImpl !== 'function') {
throw new TypeError('A fetch implementation is required to refresh node information');
}
const identifier = normalized.nodeId ?? normalized.nodeNum;
if (identifier == null) {
throw new Error('A node identifier or numeric reference must be provided');
}
const encodedId = encodeURIComponent(String(identifier));
const [nodeRecord, telemetryRecords, positionRecords, neighborRecords] = await Promise.all([
(async () => {
const response = await fetchImpl(`/api/nodes/${encodedId}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Failed to load node information (HTTP ${response.status})`);
}
return response.json();
})(),
(async () => {
const response = await fetchImpl(`/api/telemetry/${encodedId}?limit=${TELEMETRY_LIMIT}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Failed to load telemetry information (HTTP ${response.status})`);
}
return response.json();
})(),
(async () => {
const response = await fetchImpl(`/api/positions/${encodedId}?limit=${POSITION_LIMIT}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Failed to load position information (HTTP ${response.status})`);
}
return response.json();
})(),
(async () => {
const response = await fetchImpl(`/api/neighbors/${encodedId}?limit=${NEIGHBOR_LIMIT}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) return [];
if (!response.ok) {
throw new Error(`Failed to load neighbor information (HTTP ${response.status})`);
}
return response.json();
})(),
]);
const telemetryEntry = Array.isArray(telemetryRecords) ? telemetryRecords[0] ?? null : telemetryRecords ?? null;
const positionEntry = Array.isArray(positionRecords) ? positionRecords[0] ?? null : positionRecords ?? null;
const neighborEntries = Array.isArray(neighborRecords) ? neighborRecords.filter(isObject) : [];
const node = { neighbors: neighborEntries };
if (normalized.fallback) {
mergeNodeFields(node, normalized.fallback);
}
if (nodeRecord) {
mergeNodeFields(node, nodeRecord);
}
if (normalized.nodeId && !node.nodeId) {
node.nodeId = normalized.nodeId;
}
if (normalized.nodeNum != null && toFiniteNumber(node.nodeNum) == null) {
node.nodeNum = normalized.nodeNum;
}
mergeTelemetry(node, telemetryEntry);
mergePosition(node, positionEntry);
const derivedLastHeardValues = [
toFiniteNumber(node.lastHeard),
toFiniteNumber(node.telemetryTime),
toFiniteNumber(node.positionTime),
].filter(value => value != null);
if (derivedLastHeardValues.length > 0) {
node.lastHeard = Math.max(...derivedLastHeardValues);
}
if (!node.role) {
node.role = 'CLIENT';
}
node.rawSources = {
node: nodeRecord,
telemetry: telemetryEntry,
position: positionEntry,
neighbors: neighborEntries,
};
return node;
}
export const __testUtils = {
toTrimmedString,
toFiniteNumber,
extractString,
extractNumber,
assignString,
assignNumber,
mergeNodeFields,
mergeTelemetry,
mergePosition,
parseFallback,
normalizeReference,
};
+184
View File
@@ -2252,6 +2252,40 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
end
it "excludes nodes whose last activity is older than a week" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_last = now - (PotatoMesh::Config.week_seconds + 60)
fresh_last = now - 30
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!stale-node", "stal", "Stale", "TBEAM", "CLIENT", 0.0, stale_last, stale_last],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!fresh-node", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_last, fresh_last],
)
end
get "/api/nodes"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["node_id"] }
expect(ids).to include("!fresh-node")
expect(ids).not_to include("!stale-node")
get "/api/nodes/!stale-node"
expect(last_response.status).to eq(404)
get "/api/nodes/!fresh-node"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!fresh-node")
end
end
describe "GET /api/messages" do
@@ -2443,6 +2477,43 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(messages.first["from_id"]).to be_nil
end
end
it "omits messages received more than seven days ago" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 120)
fresh_rx = now - 15
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!fresh", "frsh", "Fresh", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
[1, stale_rx, Time.at(stale_rx).utc.iso8601, "!old", "!fresh", 0, "TEXT_MESSAGE_APP", "stale", 1.0, -70, 3],
)
db.execute(
"INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, portnum, text, snr, rssi, hop_limit) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
[2, fresh_rx, Time.at(fresh_rx).utc.iso8601, "!fresh", "!old", 0, "TEXT_MESSAGE_APP", "fresh", 2.0, -60, 3],
)
end
get "/api/messages"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to include(2)
expect(ids).not_to include(1)
get "/api/messages/!old"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
end
context "when private mode is enabled" do
@@ -2533,6 +2604,87 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(entry["precision_bits"]).to eq(8)
expect(entry["payload_b64"]).to eq("AQI=")
end
it "excludes position entries older than seven days" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 10)
fresh_rx = now - 20
with_db do |db|
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[1, "!pos", 42, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 5, 52.0, 13.0],
)
db.execute(
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
[2, "!pos", 42, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 5, 53.0, 14.0],
)
end
get "/api/positions"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to eq([2])
get "/api/positions/!pos"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
end
describe "GET /api/neighbors" do
it "excludes neighbor records older than seven days" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 45)
fresh_rx = now - 10
with_db do |db|
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!root", "root", "Root", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, fresh_rx, fresh_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!root", "!neighbor-old", 1.0, stale_rx],
)
db.execute(
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
["!root", "!neighbor-new", 8.0, fresh_rx],
)
end
get "/api/neighbors"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload.length).to eq(1)
expect(payload.first["neighbor_id"]).to eq("!neighbor-new")
expect(payload.first["rx_time"]).to eq(fresh_rx)
get "/api/neighbors/!root"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
expect(filtered.first["rx_time"]).to eq(fresh_rx)
end
end
describe "GET /api/telemetry" do
@@ -2565,5 +2717,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(second_entry["relative_humidity"]).to be_within(1e-6).of(second_latest["environment_metrics"]["relativeHumidity"])
expect(second_entry["barometric_pressure"]).to be_within(1e-6).of(second_latest["environment_metrics"]["barometricPressure"])
end
it "excludes telemetry entries older than seven days" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 30)
fresh_rx = now - 5
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[1, "!tele", 7, stale_rx, Time.at(stale_rx).utc.iso8601, stale_rx - 60, 10.0, 3.9],
)
db.execute(
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
[2, "!tele", 7, fresh_rx, Time.at(fresh_rx).utc.iso8601, fresh_rx - 60, 90.0, 4.1],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
ids = payload.map { |row| row["id"] }
expect(ids).to eq([2])
get "/api/telemetry/!tele"
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
end
end
end