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} 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, +}; diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 9af10db..53fbba1 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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