Compare commits

..

13 Commits

Author SHA1 Message Date
l5y e1d43cec57 Added comprehensive helper unit tests (#457)
* Added comprehensive helper unit tests

* run black
2025-11-16 16:47:57 +01:00
l5y cd7bced827 Added reaction-aware handling (#455) 2025-11-16 15:31:17 +01:00
l5y b298f2f22c env: add map zoom (#454)
* chore: bump version to 0.5.5 everywhere

* add MAP_ZOOM varibale

* run black
2025-11-16 12:57:47 +01:00
l5y 9304a99745 charts: render aggregated telemetry charts for all nodes (#453) 2025-11-15 17:09:55 +01:00
l5y 4a03e17886 nodes: render charts detail pages as overlay (#452) 2025-11-15 12:13:06 +01:00
l5y e502ddd436 fix telemetry parsing for charts (#451) 2025-11-14 21:18:37 +01:00
l5y 12f1801ed2 nodes: improve charts on detail pages (#450)
* nodes: add charts to detail pages

* nodes: improve charts on detail pages

* fix ignored packet debug loggin

* run rufo

* address review comments
2025-11-14 20:17:58 +01:00
l5y a6a63bf12e nodes: add charts to detail pages (#449) 2025-11-14 16:24:09 +01:00
l5y 631455237f Aggregate frontend snapshots across views (#447) 2025-11-13 22:02:42 +01:00
Alexkurd 382e2609c9 Remove added 1 if reply with emoji (#443)
In reply message.text contains emoji, and message.emoji is 1.
2025-11-13 21:15:35 +01:00
l5y 05efbc5f20 Refine node detail view layout (#442)
* Refine node detail view layout

* Refine node detail controls and formatting

* Improve node detail neighbor roles and message metadata

* Fix node detail neighbor metadata hydration
2025-11-13 19:59:07 +01:00
l5y 9a45430321 Enable map centering from node table coordinates (#439)
* Enable map centering from node table coordinates

* Replace node coordinate buttons with links
2025-11-13 17:23:35 +01:00
l5y cb843d5774 Add node detail route and page (#441) 2025-11-13 17:19:20 +01:00
51 changed files with 6277 additions and 122 deletions
+3
View File
@@ -73,3 +73,6 @@ web/.config
# JavaScript dependencies
node_modules/
web/node_modules/
# Debug symbols
ignored.txt
+1
View File
@@ -47,6 +47,7 @@ Additional environment variables are optional:
| `FREQUENCY` | `"915MHz"` | Default LoRa frequency description shown in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix room alias rendered in UI footers and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map view. |
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom (disables the auto-fit checkbox when set). |
| `MAX_DISTANCE` | `42` | Maximum relationship distance (km) before edges are hidden. |
| `DEBUG` | `0` | Enables verbose logging across services when set to `1`. |
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
+1
View File
@@ -84,6 +84,7 @@ ENV APP_ENV=production \
CHANNEL="#LongFast" \
FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0
+2 -1
View File
@@ -79,6 +79,7 @@ The web app can be configured with environment variables (defaults shown):
| `FREQUENCY` | `"915MHz"` | Default frequency description displayed in the UI. |
| `CONTACT_LINK` | `"#potatomesh:dod.ngo"` | Chat link or Matrix alias rendered in the footer and overlays. |
| `MAP_CENTER` | `38.761944,-27.090833` | Latitude and longitude that centre the map on load. |
| `MAP_ZOOM` | _unset_ | Fixed Leaflet zoom applied on first load; disables auto-fit when provided. |
| `MAX_DISTANCE` | `42` | Maximum distance (km) before node relationships are hidden on the map. |
| `DEBUG` | `0` | Set to `1` for verbose logging in the web and ingestor services. |
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
@@ -92,7 +93,7 @@ logo for Open Graph and Twitter cards.
Example:
```bash
SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh
SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAP_ZOOM=11 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh
```
### Configuration & Storage
+12
View File
@@ -77,6 +77,7 @@ FREQUENCY=$(grep "^FREQUENCY=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' ||
FEDERATION=$(grep "^FEDERATION=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "1")
PRIVATE=$(grep "^PRIVATE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "0")
MAP_CENTER=$(grep "^MAP_CENTER=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "38.761944,-27.090833")
MAP_ZOOM=$(grep "^MAP_ZOOM=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "42")
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
@@ -90,6 +91,7 @@ echo "📍 Location Settings"
echo "-------------------"
read_with_default "Site Name (your mesh network name)" "$SITE_NAME" SITE_NAME
read_with_default "Map Center (lat,lon)" "$MAP_CENTER" MAP_CENTER
read_with_default "Default map zoom (leave blank to auto-fit)" "$MAP_ZOOM" MAP_ZOOM
read_with_default "Max Distance (km)" "$MAX_DISTANCE" MAX_DISTANCE
echo ""
@@ -180,6 +182,11 @@ update_env "SITE_NAME" "\"$SITE_NAME\""
update_env "CHANNEL" "\"$CHANNEL\""
update_env "FREQUENCY" "\"$FREQUENCY\""
update_env "MAP_CENTER" "\"$MAP_CENTER\""
if [ -n "$MAP_ZOOM" ]; then
update_env "MAP_ZOOM" "$MAP_ZOOM"
else
sed -i.bak '/^MAP_ZOOM=.*/d' .env
fi
update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "DEBUG" "$DEBUG"
@@ -222,6 +229,11 @@ echo ""
echo "📋 Your settings:"
echo " Site Name: $SITE_NAME"
echo " Map Center: $MAP_CENTER"
if [ -n "$MAP_ZOOM" ]; then
echo " Map Zoom: $MAP_ZOOM"
else
echo " Map Zoom: Auto-fit"
fi
echo " Max Distance: ${MAX_DISTANCE}km"
echo " Channel: $CHANNEL"
echo " Frequency: $FREQUENCY"
+5
View File
@@ -17,3 +17,8 @@
The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.5.5"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+2 -2
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from . import channels, config, queue
_IGNORED_PACKET_LOG_PATH = Path(__file__).resolve().parents[2] / "ingored.txt"
_IGNORED_PACKET_LOG_PATH = Path(__file__).resolve().parents[2] / "ignored.txt"
"""Filesystem path that stores ignored packets when debugging."""
_IGNORED_PACKET_LOCK = threading.Lock()
@@ -52,7 +52,7 @@ def _ignored_packet_default(value: object) -> object:
def _record_ignored_packet(packet: Mapping | object, *, reason: str) -> None:
"""Persist packet details to :data:`ingored.txt` during debugging."""
"""Persist packet details to :data:`ignored.txt` during debugging."""
if not config.DEBUG:
return
+1
View File
@@ -21,6 +21,7 @@ x-web-base: &web-base
CHANNEL: ${CHANNEL:-#LongFast}
FREQUENCY: ${FREQUENCY:-915MHz}
MAP_CENTER: ${MAP_CENTER:-38.761944,-27.090833}
MAP_ZOOM: ${MAP_ZOOM:-""}
MAX_DISTANCE: ${MAX_DISTANCE:-42}
CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo}
FEDERATION: ${FEDERATION:-1}
+216
View File
@@ -0,0 +1,216 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Additional tests that exercise defensive helpers and interfaces."""
import importlib
import sys
import types
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor import channels, config, interfaces, queue, serialization
@pytest.fixture(autouse=True)
def reset_state(monkeypatch):
"""Ensure mutable singletons are cleaned up between tests."""
repo_root = Path(__file__).resolve().parents[1]
monkeypatch.syspath_prepend(str(repo_root))
channels._reset_channel_cache()
yield
channels._reset_channel_cache()
importlib.reload(config)
def test_config_module_port_aliases(monkeypatch):
"""Ensure the config module keeps CONNECTION and PORT in sync."""
reloaded = importlib.reload(config)
monkeypatch.setattr(reloaded, "CONNECTION", "dev-tty", raising=False)
reloaded.PORT = "new-port"
assert reloaded.CONNECTION == "new-port"
assert reloaded.PORT == "new-port"
def test_queue_stringification_and_ordering():
"""Exercise queue payload formatting and priority ordering."""
mapping_payload = {"b": 1, "a": 2}
assert queue._stringify_payload_value(mapping_payload).startswith('{"a"')
assert queue._stringify_payload_value([1, 2, 3]).startswith("[1")
assert queue._stringify_payload_value({1, 2}).replace(" ", "") in ("[1,2]", "[2,1]")
assert queue._stringify_payload_value(b"bytes") == '"bytes"'
assert queue._stringify_payload_value("text") == '"text"'
pairs = queue._payload_key_value_pairs(mapping_payload)
assert pairs.split(" ") == ["a=2", "b=1"]
state = queue.QueueState()
order = []
queue._enqueue_post_json("/low", {"x": 1}, priority=90, state=state)
queue._enqueue_post_json("/high", {"x": 2}, priority=10, state=state)
state.active = True
queue._drain_post_queue(
state=state, send=lambda path, payload: order.append((path, payload["x"]))
)
assert order == [("/high", 2), ("/low", 1)]
assert state.active is False
assert state.queue == []
def test_channels_iterator_and_capture(monkeypatch):
"""Verify channel helpers normalise roles and cache primary/secondary entries."""
channels._reset_channel_cache()
class StubSettings:
def __init__(self, name):
self.name = name
class PrimaryChannel:
def __init__(self):
self.role = "PRIMARY"
self.settings = StubSettings("Alpha")
class SecondaryChannel:
def __init__(self, index, name):
self.role = "SECONDARY"
self.index = index
self.settings = StubSettings(name)
class Container:
def __len__(self):
return 2
def __getitem__(self, idx):
if idx == 0:
return PrimaryChannel()
if idx == 1:
return SecondaryChannel(5, "Bravo")
raise IndexError
class StubLocalNode:
def __init__(self):
self.channels = Container()
class StubIface:
def __init__(self):
self.localNode = StubLocalNode()
def waitForConfig(self):
return True
channels.capture_from_interface(StubIface())
assert channels.channel_mappings() == ((0, "Alpha"), (5, "Bravo"))
assert channels.channel_name(5) == "Bravo"
assert list(channels._iter_channel_objects({"0": "zero"})) == ["zero"]
def test_candidate_node_id_and_normaliser():
"""Ensure node identifiers are found inside nested payloads."""
nested = {
"payload": {"meta": {"user": {"id": "0x42"}}},
"decoded": {"from": "!0000002a"},
}
node_id = interfaces._candidate_node_id(nested)
assert node_id == "!0000002a"
packet = {"user": {"id": "!0000002a"}, "userId": None}
normalised = interfaces._normalise_nodeinfo_packet(packet)
assert normalised["id"] == "!0000002a"
assert normalised["user"]["id"] == "!0000002a"
def test_safe_nodeinfo_wrapper_handles_missing_id():
"""Cover the KeyError guard and wrapper marker."""
called = {}
def original(_iface, _packet):
called["ran"] = True
raise KeyError("id")
wrapper = interfaces._build_safe_nodeinfo_callback(original)
result = wrapper(SimpleNamespace(), {"anything": 1})
assert called["ran"] is True
assert result is None
assert getattr(wrapper, "_potato_mesh_safe_wrapper")
def test_patch_nodeinfo_handler_class(monkeypatch):
"""Ensure NodeInfoHandler subclasses normalise packets with missing ids."""
class DummyHandler:
def __init__(self):
self.calls = []
def onReceive(self, iface, packet):
self.calls.append(packet)
return packet.get("id")
mesh_interface = types.SimpleNamespace(
NodeInfoHandler=DummyHandler, __name__="meshtastic.mesh_interface"
)
interfaces._patch_nodeinfo_handler_class(mesh_interface)
handler_cls = mesh_interface.NodeInfoHandler
handler = handler_cls()
iface = SimpleNamespace()
packet = {"user": {"id": "abcd"}}
result = handler.onReceive(iface, packet)
assert result == serialization._canonical_node_id("abcd")
assert handler.calls[0]["id"] == serialization._canonical_node_id("abcd")
def test_region_frequency_and_resolution_helpers():
"""Cover enum name parsing for LoRa region frequency."""
class EnumValue:
def __init__(self, name):
self.name = name
class EnumType:
def __init__(self):
self.values_by_number = {1: EnumValue("REGION_915")}
class FieldDesc:
def __init__(self):
self.enum_type = EnumType()
class Descriptor:
def __init__(self):
self.fields_by_name = {"region": FieldDesc()}
class LoraMessage:
def __init__(self, region):
self.region = region
self.DESCRIPTOR = Descriptor()
freq = interfaces._region_frequency(LoraMessage(1))
assert freq == 915
class LocalConfig:
def __init__(self, lora):
self.lora = lora
lora_msg = LoraMessage(1)
resolved = interfaces._resolve_lora_message(LocalConfig(lora_msg))
assert resolved is lora_msg
+1 -1
View File
@@ -2331,7 +2331,7 @@ def test_store_packet_dict_records_ignored_packets(mesh_module, monkeypatch, tmp
mesh = mesh_module
monkeypatch.setattr(mesh, "DEBUG", True)
ignored_path = tmp_path / "ingored.txt"
ignored_path = tmp_path / "ignored.txt"
monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOG_PATH", ignored_path)
monkeypatch.setattr(mesh.handlers, "_IGNORED_PACKET_LOCK", threading.Lock())
+69
View File
@@ -0,0 +1,69 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Ensure version identifiers stay synchronised across all packages."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
import data
def _ruby_fallback_version() -> str:
config_path = REPO_ROOT / "web" / "lib" / "potato_mesh" / "config.rb"
contents = config_path.read_text(encoding="utf-8")
inside = False
for line in contents.splitlines():
stripped = line.strip()
if stripped.startswith("def version_fallback"):
inside = True
continue
if inside and stripped == "end":
break
if inside:
literal = re.search(r"['\"](?P<version>[^'\"]+)['\"]", stripped)
if literal:
return literal.group("version")
raise AssertionError("Unable to locate version_fallback definition in config.rb")
def _javascript_package_version() -> str:
package_path = REPO_ROOT / "web" / "package.json"
data = json.loads(package_path.read_text(encoding="utf-8"))
version = data.get("version")
if isinstance(version, str):
return version
raise AssertionError("package.json does not expose a string version")
def test_version_identifiers_match_across_languages() -> None:
"""Guard against version drift between Python, Ruby, and JavaScript."""
python_version = getattr(data, "__version__", None)
assert (
isinstance(python_version, str) and python_version
), "data.__version__ missing"
ruby_version = _ruby_fallback_version()
javascript_version = _javascript_package_version()
assert python_version == ruby_version == javascript_version
+1
View File
@@ -91,6 +91,7 @@ ENV RACK_ENV=production \
CHANNEL="#LongFast" \
FREQUENCY="915MHz" \
MAP_CENTER="38.761944,-27.090833" \
MAP_ZOOM="" \
MAX_DISTANCE=42 \
CONTACT_LINK="#potatomesh:dod.ngo" \
DEBUG=0
@@ -122,6 +122,7 @@ module PotatoMesh
lat: PotatoMesh::Config.map_center_lat,
lon: PotatoMesh::Config.map_center_lon,
},
mapZoom: PotatoMesh::Config.map_zoom,
maxDistanceKm: PotatoMesh::Config.max_distance_km,
tileFilters: PotatoMesh::Config.tile_filters,
instanceDomain: app_constant(:INSTANCE_DOMAIN),
@@ -158,6 +159,67 @@ module PotatoMesh
PotatoMesh::Meta.formatted_distance_km(distance)
end
# Build the canonical node detail path for the supplied identifier.
#
# @param identifier [String, nil] node identifier in ``!xxxx`` notation.
# @return [String, nil] detail path including the canonical ``!`` prefix.
def node_detail_path(identifier)
ident = string_or_nil(identifier)
return nil unless ident && !ident.empty?
trimmed = ident.strip
return nil if trimmed.empty?
body = trimmed.start_with?("!") ? trimmed[1..-1] : trimmed
return nil unless body && !body.empty?
escaped = Rack::Utils.escape_path(body)
"/nodes/!#{escaped}"
end
# Present a version string with a leading ``v`` when missing to keep
# UI labels consistent across tagged and fallback builds.
#
# @param version [String, nil] raw application version string.
# @return [String, nil] version string prefixed with ``v`` when needed.
def display_version(version)
return nil if version.nil? || version.to_s.strip.empty?
text = version.to_s.strip
text.start_with?("v") ? text : "v#{text}"
end
# Render a linked long name pointing to the node detail page.
#
# @param long_name [String] display name for the node.
# @param identifier [String, nil] canonical node identifier.
# @param css_class [String, nil] optional CSS class applied to the anchor.
# @return [String] escaped HTML snippet.
def node_long_name_link(long_name, identifier, css_class: "node-long-link")
text = string_or_nil(long_name)
return "" unless text
href = node_detail_path(identifier)
escaped_text = Rack::Utils.escape_html(text)
return escaped_text unless href
canonical_identifier = canonical_node_identifier(identifier)
class_attr = css_class ? %( class="#{css_class}") : ""
data_attrs = %( data-node-detail-link="true")
if canonical_identifier
escaped_identifier = Rack::Utils.escape_html(canonical_identifier)
data_attrs = %(#{data_attrs} data-node-id="#{escaped_identifier}")
end
%(<a#{class_attr} href="#{href}"#{data_attrs}>#{escaped_text}</a>)
end
# Normalise a node identifier by ensuring the canonical ``!`` prefix.
#
# @param identifier [String, nil] raw identifier string.
# @return [String, nil] canonical identifier or ``nil`` when unavailable.
def canonical_node_identifier(identifier)
ident = string_or_nil(identifier)
return nil unless ident && !ident.empty?
trimmed = ident.strip
return nil if trimmed.empty?
trimmed.start_with?("!") ? trimmed : "!#{trimmed}"
end
# Generate the meta description used in SEO tags.
#
# @return [String] combined descriptive sentence.
@@ -18,6 +18,11 @@ module PotatoMesh
module App
module Routes
module Api
# Register read-only API endpoints that expose cached mesh data and
# instance metadata. Invoked by Sinatra during extension registration.
#
# @param app [Sinatra::Base] application instance receiving the routes.
# @return [void]
def self.registered(app)
app.before "/api/messages*" do
halt 404 if private_mode?
@@ -18,6 +18,11 @@ module PotatoMesh
module App
module Routes
module Ingest
# Register ingest endpoints used by the Python collector to persist
# nodes, messages, and federation announcements.
#
# @param app [Sinatra::Base] application instance receiving the routes.
# @return [void]
def self.registered(app)
app.post "/api/nodes" do
require_token!
+127 -24
View File
@@ -42,33 +42,108 @@ module PotatoMesh
#
# @param template [Symbol] identifier for the ERB template.
# @param view_mode [Symbol, String] logical view identifier for CSS hooks.
# @param extra_locals [Hash] additional locals merged into the rendering context.
# @return [String] rendered ERB output.
def render_root_view(template, view_mode: :dashboard)
def render_root_view(template, view_mode: :dashboard, extra_locals: {})
meta = meta_configuration
config = frontend_app_config
theme = resolve_initial_theme
view_mode_sym = view_mode.respond_to?(:to_sym) ? view_mode.to_sym : view_mode
erb template, layout: :"layouts/app", locals: {
site_name: meta[:name],
meta_title: meta[:title],
meta_name: meta[:name],
meta_description: meta[:description],
channel: sanitized_channel,
frequency: sanitized_frequency,
map_center_lat: PotatoMesh::Config.map_center_lat,
map_center_lon: PotatoMesh::Config.map_center_lon,
max_distance_km: PotatoMesh::Config.max_distance_km,
contact_link: sanitized_contact_link,
contact_link_url: sanitized_contact_link_url,
version: app_constant(:APP_VERSION),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
current_view_mode: view_mode_sym,
}
base_locals = {
site_name: meta[:name],
meta_title: meta[:title],
meta_name: meta[:name],
meta_description: meta[:description],
channel: sanitized_channel,
frequency: sanitized_frequency,
map_center_lat: PotatoMesh::Config.map_center_lat,
map_center_lon: PotatoMesh::Config.map_center_lon,
max_distance_km: PotatoMesh::Config.max_distance_km,
contact_link: sanitized_contact_link,
contact_link_url: sanitized_contact_link_url,
version: display_version(app_constant(:APP_VERSION)),
private_mode: private_mode?,
federation_enabled: federation_enabled?,
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
app_config_json: JSON.generate(config),
initial_theme: theme,
current_view_mode: view_mode_sym,
map_zoom: PotatoMesh::Config.map_zoom,
}
sanitized_locals = extra_locals.is_a?(Hash) ? extra_locals : {}
merged_locals = base_locals.merge(sanitized_locals)
erb template, layout: :"layouts/app", locals: merged_locals
end
# Remove keys with +nil+ values from the provided hash, returning a
# shallow copy. Hash#compact is only available in newer Ruby
# versions; this helper keeps behaviour consistent across supported
# releases.
#
# @param value [Hash, nil] collection subject to filtering.
# @return [Hash] hash excluding +nil+ values.
def reject_nil_values(value)
return {} unless value.is_a?(Hash)
value.each_with_object({}) do |(key, entry), memo|
memo[key] = entry unless entry.nil?
end
end
# Assemble the payload embedded into the node detail view. The
# payload provides a canonical identifier alongside any cached node,
# telemetry, or position rows that may already exist in the
# database. When no persisted data is available the method returns
# +nil+ so the caller can surface a 404 error.
#
# @param node_ref [Object] raw node identifier from the request.
# @return [Hash, nil] structured node reference payload or nil when
# the node cannot be located.
def build_node_detail_reference(node_ref)
tokens = canonical_node_parts(node_ref)
search_ref = tokens ? tokens.first : node_ref
node_row = query_nodes(1, node_ref: search_ref).first
telemetry_row = query_telemetry(1, node_ref: search_ref).first
position_row = query_positions(1, node_ref: search_ref).first
candidates = [node_row, telemetry_row, position_row].compact
return nil if candidates.empty?
canonical_id = string_or_nil(node_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(telemetry_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(position_row&.fetch("node_id", nil))
canonical_id ||= string_or_nil(tokens&.fetch(0, nil))
if canonical_id
canonical_id = canonical_id.start_with?("!") ? canonical_id : "!#{canonical_id}"
end
return nil unless canonical_id
numeric_id = coerce_integer(node_row&.fetch("num", nil))
numeric_id ||= coerce_integer(telemetry_row&.fetch("node_num", nil))
numeric_id ||= coerce_integer(position_row&.fetch("node_num", nil))
numeric_id ||= tokens&.fetch(1, nil)
short_id = string_or_nil(node_row&.fetch("short_name", nil))
short_id ||= string_or_nil(telemetry_row&.fetch("short_name", nil))
short_id ||= string_or_nil(position_row&.fetch("short_name", nil))
short_id ||= tokens&.fetch(2, nil)
fallback_row = node_row || telemetry_row || position_row
fallback = fallback_row ? compact_api_row(fallback_row) : nil
telemetry = telemetry_row ? compact_api_row(telemetry_row) : nil
position = position_row ? compact_api_row(position_row) : nil
{
"nodeId" => canonical_id,
"nodeNum" => numeric_id,
"shortId" => short_id,
"fallback" => fallback,
"telemetry" => telemetry,
"position" => position,
}
end
end
@@ -99,15 +174,43 @@ module PotatoMesh
render_root_view(:index, view_mode: :dashboard)
end
app.get "/map" do
app.get %r{/map/?} do
render_root_view(:map, view_mode: :map)
end
app.get "/chat" do
app.get %r{/chat/?} do
render_root_view(:chat, view_mode: :chat)
end
app.get "/nodes" do
app.get %r{/charts/?} do
render_root_view(:charts, view_mode: :charts)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
halt 404, "Not Found" unless reference_payload
fallback = reference_payload["fallback"] || {}
short_name = string_or_nil(fallback["short_name"]) || reference_payload["shortId"]
long_name = string_or_nil(fallback["long_name"])
role = string_or_nil(fallback["role"])
canonical_id = string_or_nil(reference_payload["nodeId"])
render_root_view(
:node_detail,
view_mode: :node_detail,
extra_locals: {
node_reference_json: JSON.generate(reject_nil_values(reference_payload)),
node_page_short_name: short_name,
node_page_long_name: long_name,
node_page_role: role,
node_page_identifier: canonical_id,
},
)
end
app.get %r{/nodes/?} do
render_root_view(:nodes, view_mode: :nodes)
end
+15 -1
View File
@@ -175,7 +175,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.5"
"0.5.5"
end
# Default refresh interval for frontend polling routines.
@@ -477,6 +477,20 @@ module PotatoMesh
map_center[:lon]
end
# Retrieve an explicit map zoom override when provided.
#
# @return [Float, nil] positive zoom value or +nil+ when unset.
def map_zoom
raw = fetch_string("MAP_ZOOM", nil)
return nil unless raw
zoom = Float(raw, exception: false)
return nil unless zoom
return nil unless zoom.positive?
zoom
end
# Maximum straight-line distance between nodes before relationships are
# hidden.
#
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.0",
"version": "0.5.5",
"type": "module",
"private": true,
"scripts": {
@@ -0,0 +1,149 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
fetchAggregatedTelemetry,
initializeChartsPage,
buildMovingAverageSeries,
} from '../charts-page.js';
function createResponse(status, body) {
return {
ok: status >= 200 && status < 300,
status,
async json() {
return body;
},
};
}
test('fetchAggregatedTelemetry requests the latest 1000 telemetry entries', async () => {
const requests = [];
const fetchImpl = async url => {
requests.push(url);
return createResponse(200, [{ rx_time: 1_700_000_000, node_id: '!demo' }]);
};
const snapshots = await fetchAggregatedTelemetry({ fetchImpl });
assert.equal(requests.length, 1);
assert.equal(requests[0], '/api/telemetry?limit=1000');
assert.equal(Array.isArray(snapshots), true);
assert.equal(snapshots[0].node_id, '!demo');
});
test('fetchAggregatedTelemetry validates fetch availability and response codes', async () => {
await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl: null }), /fetch implementation/i);
const fetchImpl = async () => createResponse(503, []);
await assert.rejects(() => fetchAggregatedTelemetry({ fetchImpl }), /Failed to fetch telemetry/);
});
test('initializeChartsPage renders the telemetry charts when snapshots are available', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById(id) {
return id === 'chartsPage' ? container : null;
},
};
const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000, temperature: 22.5 }]);
let receivedOptions = null;
const renderCharts = (node, options) => {
receivedOptions = options;
return '<section class="node-detail__charts">Charts</section>';
};
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
assert.ok(receivedOptions);
assert.equal(receivedOptions.chartOptions.windowMs, 86_400_000);
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
const average = receivedOptions.chartOptions.lineReducer(
[
{ timestamp: 0, value: 0 },
{ timestamp: 1_800_000, value: 10 },
{ timestamp: 3_600_000, value: 20 },
],
);
assert.equal(Array.isArray(average), true);
});
test('initializeChartsPage shows an error message when fetching fails', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => {
throw new Error('network');
};
const renderCharts = () => '<section>unused</section>';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, false);
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
});
test('initializeChartsPage handles missing containers and empty telemetry snapshots', async () => {
const documentMissing = { getElementById() { return null; } };
const noneResult = await initializeChartsPage({ document: documentMissing });
assert.equal(noneResult, false);
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => createResponse(200, []);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
test('initializeChartsPage shows a status when rendering produces no markup', async () => {
const container = { innerHTML: '' };
const documentStub = {
getElementById() {
return container;
},
};
const fetchImpl = async () => createResponse(200, [{ rx_time: 1_700_000_000 }]);
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
test('initializeChartsPage validates the document contract', async () => {
await assert.rejects(() => initializeChartsPage({ document: {} }), /getElementById/);
});
test('buildMovingAverageSeries computes a rolling mean across the window', () => {
const points = [
{ timestamp: 0, value: 0 },
{ timestamp: 30 * 60 * 1000, value: 30 },
{ timestamp: 60 * 60 * 1000, value: 60 },
{ timestamp: 90 * 60 * 1000, value: 90 },
];
const averages = buildMovingAverageSeries(points, 60 * 60 * 1000);
assert.equal(averages.length, points.length);
assert.equal(Math.round(averages[0].value), 0);
assert.equal(Math.round(averages[1].value), 15);
assert.equal(Math.round(averages[2].value), 30);
assert.equal(Math.round(averages[3].value), 60);
});
@@ -81,6 +81,7 @@ test('mergeConfig coerces numeric values and nested objects', () => {
refreshMs: '45000',
mapCenter: { lat: '10.5', lon: '20.1' },
tileFilters: { dark: 'contrast(2)' },
mapZoom: '12',
chatEnabled: 0,
channel: '#Custom',
frequency: '915MHz',
@@ -93,6 +94,7 @@ test('mergeConfig coerces numeric values and nested objects', () => {
assert.equal(result.refreshMs, 45000);
assert.deepEqual(result.mapCenter, { lat: 10.5, lon: 20.1 });
assert.deepEqual(result.tileFilters, { light: DEFAULT_CONFIG.tileFilters.light, dark: 'contrast(2)' });
assert.equal(result.mapZoom, 12);
assert.equal(result.chatEnabled, false);
assert.equal(result.channel, '#Custom');
assert.equal(result.frequency, '915MHz');
@@ -105,12 +107,19 @@ test('mergeConfig falls back to defaults for invalid numeric values', () => {
const result = mergeConfig({
refreshIntervalSeconds: 'NaN',
refreshMs: 'NaN',
maxDistanceKm: 'oops'
maxDistanceKm: 'oops',
mapZoom: 'not-a-number'
});
assert.equal(result.refreshIntervalSeconds, DEFAULT_CONFIG.refreshIntervalSeconds);
assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs);
assert.equal(result.maxDistanceKm, DEFAULT_CONFIG.maxDistanceKm);
assert.equal(result.mapZoom, null);
});
test('mergeConfig treats blank mapZoom as null', () => {
const result = mergeConfig({ mapZoom: '' });
assert.equal(result.mapZoom, null);
});
test('document stub returns null for unrelated selectors', () => {
@@ -73,3 +73,32 @@ test('resolveReplyPrefix renders reply badge and buildMessageBody joins emoji',
assert.equal(body, 'ESC(Hello) EMOJI(🔥)');
});
test('buildMessageBody suppresses reaction slot markers and formats counts', () => {
const reaction = {
text: ' 1 ',
emoji: '👍',
portnum: 'REACTION_APP',
reply_id: 123,
};
const body = buildMessageBody({
message: reaction,
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(body, 'EMOJI(👍)');
const countedReaction = {
text: '2',
emoji: '✨',
reply_id: 123
};
const countedBody = buildMessageBody({
message: countedReaction,
escapeHtml: value => `ESC(${value})`,
renderEmojiHtml: value => `EMOJI(${value})`
});
assert.equal(countedBody, 'ESC(×2) EMOJI(✨)');
});
@@ -0,0 +1,134 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createNodeDetailOverlayManager } from '../node-detail-overlay.js';
function createOverlayHarness() {
const overlayListeners = new Map();
const documentListeners = new Map();
const content = { innerHTML: '' };
const closeButton = {
listeners: new Map(),
focusCalled: false,
addEventListener(event, handler) {
this.listeners.set(event, handler);
},
click() {
const handler = this.listeners.get('click');
if (handler) handler({ preventDefault() {} });
},
focus() {
this.focusCalled = true;
},
};
const dialog = {
focusCalled: false,
focus() {
this.focusCalled = true;
},
};
const overlay = {
hidden: true,
style: {},
addEventListener(event, handler) {
overlayListeners.set(event, handler);
},
trigger(event, payload) {
const handler = overlayListeners.get(event);
if (handler) handler(payload);
},
querySelector(selector) {
if (selector === '.node-detail-overlay__dialog') return dialog;
if (selector === '.node-detail-overlay__close') return closeButton;
if (selector === '.node-detail-overlay__content') return content;
return null;
},
};
const body = {
style: {
overflow: '',
removeProperty(prop) {
this[prop] = '';
},
},
};
const document = {
body,
getElementById(id) {
return id === 'nodeDetailOverlay' ? overlay : null;
},
addEventListener(event, handler) {
documentListeners.set(event, handler);
},
removeEventListener(event) {
documentListeners.delete(event);
},
triggerKeydown(key) {
const handler = documentListeners.get('keydown');
if (handler) {
handler({ key, preventDefault() {} });
}
},
};
return { document, overlay, content, closeButton };
}
test('createNodeDetailOverlayManager renders fetched markup and restores focus', async () => {
const { document, overlay, content, closeButton } = createOverlayHarness();
const focusTarget = {
focusCalled: false,
focus() {
this.focusCalled = true;
},
};
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async reference => `<section class="node-detail">${reference.nodeId}</section>`,
});
assert.ok(manager);
await manager.open({ nodeId: '!alpha' }, { trigger: focusTarget, label: 'Alpha' });
assert.equal(overlay.hidden, false);
assert.equal(content.innerHTML.includes('!alpha'), true);
assert.equal(closeButton.focusCalled, true);
manager.close();
assert.equal(overlay.hidden, true);
assert.equal(focusTarget.focusCalled, true);
});
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
const { document, overlay, content } = createOverlayHarness();
const errors = [];
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async () => {
throw new Error('boom');
},
logger: {
error(err) {
errors.push(err);
},
},
});
assert.ok(manager);
await manager.open({ nodeId: '!fail' });
assert.equal(content.innerHTML.includes('Failed to load node details.'), true);
assert.equal(errors.length, 1);
document.triggerKeydown?.('Escape');
assert.equal(overlay.hidden, true);
});
@@ -45,7 +45,7 @@ function createResponse(status, body) {
test('refreshNodeInformation merges telemetry metrics when the base node lacks them', async () => {
const calls = [];
const responses = new Map([
['/api/nodes/!test', createResponse(200, {
['/api/nodes/!test?limit=7', createResponse(200, {
node_id: '!test',
short_name: 'TST',
battery_level: null,
@@ -53,14 +53,14 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
modem_preset: 'MediumFast',
lora_freq: '868.1',
})],
['/api/telemetry/!test?limit=1', createResponse(200, [{
['/api/telemetry/!test?limit=1000', 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, [{
['/api/positions/!test?limit=7', createResponse(200, [{
node_id: '!test',
latitude: 52.5,
longitude: 13.4,
@@ -113,14 +113,38 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
});
});
test('refreshNodeInformation normalizes telemetry aliases for downstream consumers', async () => {
const responses = new Map([
['/api/nodes/!chan?limit=7', createResponse(404, { error: 'not found' })],
['/api/telemetry/!chan?limit=1000', createResponse(200, [{
node_id: '!chan',
channel: '76.5',
air_util_tx: '12.25',
}])],
['/api/positions/!chan?limit=7', createResponse(404, { error: 'not found' })],
['/api/neighbors/!chan?limit=1000', createResponse(404, { error: 'not found' })],
]);
const fetchImpl = async url => responses.get(url) ?? createResponse(404, { error: 'not found' });
const node = await refreshNodeInformation('!chan', { fetchImpl });
assert.equal(node.nodeId, '!chan');
assert.equal(node.channel_utilization, 76.5);
assert.equal(node.channelUtilization, 76.5);
assert.equal(node.channel, 76.5);
assert.equal(node.air_util_tx, 12.25);
assert.equal(node.airUtil, 12.25);
assert.equal(node.airUtilTx, 12.25);
});
test('refreshNodeInformation preserves fallback metrics when telemetry is unavailable', async () => {
const responses = new Map([
['/api/nodes/42', createResponse(200, {
['/api/nodes/42?limit=7', 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/telemetry/42?limit=1000', createResponse(404, { error: 'not found' })],
['/api/positions/42?limit=7', createResponse(404, { error: 'not found' })],
['/api/neighbors/42?limit=1000', createResponse(404, { error: 'not found' })],
]);
const fetchImpl = async (url, options) => {
@@ -147,15 +171,15 @@ test('refreshNodeInformation requires a node identifier', async () => {
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, [{
['/api/nodes/!missing?limit=7', createResponse(404, { error: 'not found' })],
['/api/telemetry/!missing?limit=1000', 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, [{
['/api/positions/!missing?limit=7', createResponse(200, [{
node_id: '!missing',
latitude: 1.23,
longitude: 3.21,
@@ -0,0 +1,649 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { initializeNodeDetailPage, fetchNodeDetailHtml, __testUtils } from '../node-page.js';
const {
stringOrNull,
numberOrNull,
formatFrequency,
formatBattery,
formatVoltage,
formatUptime,
formatTimestamp,
formatMessageTimestamp,
formatHardwareModel,
formatCoordinate,
formatRelativeSeconds,
formatDurationSeconds,
formatSnr,
padTwo,
normalizeNodeId,
registerRoleCandidate,
lookupRole,
lookupNeighborDetails,
seedNeighborRoleIndex,
buildNeighborRoleIndex,
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
renderTelemetryCharts,
renderMessages,
renderNodeDetailHtml,
parseReferencePayload,
resolveRenderShortHtml,
fetchMessages,
} = __testUtils;
test('format helpers normalise values as expected', () => {
assert.equal(stringOrNull(' foo '), 'foo');
assert.equal(stringOrNull(''), null);
assert.equal(numberOrNull('42'), 42);
assert.equal(numberOrNull('abc'), null);
assert.equal(formatFrequency(915), '915.000 MHz');
assert.equal(formatFrequency('2400000'), '2.400 MHz');
assert.equal(formatFrequency('custom'), 'custom');
assert.equal(formatBattery(87.135), '87.1%');
assert.equal(formatVoltage(4.105), '4.11 V');
assert.equal(formatUptime(3661), '1h 1m 1s');
assert.match(formatTimestamp(1_700_000_000), /T/);
assert.equal(padTwo(3), '03');
assert.equal(normalizeNodeId('!NODE'), '!node');
const messageTimestamp = formatMessageTimestamp(1_700_000_000);
assert.match(messageTimestamp, /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
});
test('role lookup helpers normalise identifiers and register candidates', () => {
const index = { byId: new Map(), byNum: new Map() };
registerRoleCandidate(index, {
identifier: '!NODE',
numericId: 77,
role: 'ROUTER',
shortName: 'NODE',
longName: 'Node Long',
});
assert.equal(index.byId.get('!node'), 'ROUTER');
assert.equal(index.byNum.get(77), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!node' }), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!NODE' }), 'ROUTER');
assert.equal(lookupRole(index, { numericId: 77 }), 'ROUTER');
assert.equal(lookupRole(index, { identifier: '!missing' }), null);
const metadata = lookupNeighborDetails(index, { identifier: '!node', numericId: 77 });
assert.deepEqual(metadata, { role: 'ROUTER', shortName: 'NODE', longName: 'Node Long' });
});
test('seedNeighborRoleIndex captures known roles and missing identifiers', () => {
const index = { byId: new Map(), byNum: new Map() };
const missing = seedNeighborRoleIndex(index, [
{ neighbor_id: '!ALLY', neighbor_role: 'CLIENT', neighbor_short_name: 'ALLY' },
{ node_id: '!self', node_role: 'ROUTER' },
{ neighbor_id: '!unknown' },
]);
assert.equal(index.byId.get('!ally'), 'CLIENT');
assert.equal(index.byId.get('!self'), 'ROUTER');
assert.equal(missing.has('!unknown'), true);
const allyDetails = lookupNeighborDetails(index, { identifier: '!ally' });
assert.equal(allyDetails.shortName, 'ALLY');
});
test('additional format helpers provide table friendly output', () => {
assert.equal(formatHardwareModel('UNSET'), '');
assert.equal(formatHardwareModel('T-Beam'), 'T-Beam');
assert.equal(formatCoordinate(52.123456), '52.12346');
assert.equal(formatCoordinate(null), '');
assert.equal(formatRelativeSeconds(1_000, 1_060), '1m');
assert.equal(formatRelativeSeconds(1_000, 1_120), '2m');
assert.equal(formatRelativeSeconds(1_000, 1_000 + 3_700), '1h 1m');
assert.equal(formatRelativeSeconds(1_000, 1_000 + 90_000).startsWith('1d'), true);
assert.equal(formatDurationSeconds(59), '59s');
assert.equal(formatDurationSeconds(61), '1m 1s');
assert.equal(formatDurationSeconds(3_661), '1h 1m');
assert.equal(formatDurationSeconds(172_800), '2d');
assert.equal(formatSnr(12.345), '12.3 dB');
assert.equal(formatSnr(null), '');
const renderShortHtml = (short, role) => `<span class="short-name" data-role="${role}">${short}</span>`;
const nodeContext = {
shortName: 'NODE',
longName: 'Node Long',
role: 'CLIENT',
nodeId: '!node',
nodeNum: 77,
rawSources: { node: { node_id: '!node', role: 'CLIENT', short_name: 'NODE' } },
};
const messagesHtml = renderMessages(
[
{
text: 'hello',
rx_time: 1_700_000_400,
region_frequency: 868,
modem_preset: 'MediumFast',
channel_name: 'Primary',
node: { short_name: 'SRCE', role: 'ROUTER', node_id: '!src' },
},
{ emoji: '😊', rx_time: 1_700_000_401 },
],
renderShortHtml,
nodeContext,
);
assert.equal(messagesHtml.includes('hello'), true);
assert.equal(messagesHtml.includes('😊'), true);
assert.match(messagesHtml, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[868\]/);
assert.equal(messagesHtml.includes('[868]'), true);
assert.equal(messagesHtml.includes('[MF]'), true);
assert.equal(messagesHtml.includes('[Primary]'), true);
assert.equal(messagesHtml.includes('data-role="ROUTER"'), true);
assert.equal(messagesHtml.includes('&nbsp;&nbsp;&nbsp;'), true);
assert.equal(messagesHtml.includes('&nbsp;&nbsp;'), true);
assert.equal(messagesHtml.includes('data-role="CLIENT"'), true);
assert.equal(messagesHtml.includes(', hello'), false);
});
test('categoriseNeighbors splits inbound and outbound records', () => {
const node = { nodeId: '!self', nodeNum: 42 };
const neighbors = [
{ node_id: '!self', neighbor_id: '!ally-one' },
{ node_id: '!peer', neighbor_id: '!SELF' },
{ node_num: 42, neighbor_id: '!ally-two' },
{ node_id: '!friend', neighbor_num: 42 },
null,
];
const { heardBy, weHear } = categoriseNeighbors(node, neighbors);
assert.equal(heardBy.length, 2);
assert.equal(weHear.length, 2);
});
test('renderNeighborGroups renders grouped neighbour lists', () => {
const node = { nodeId: '!self', nodeNum: 77 };
const neighbors = [
{
node_id: '!peer',
node_short_name: 'PEER',
neighbor_id: '!self',
snr: 9.5,
node: { short_name: 'PEER', role: 'ROUTER' },
},
{
node_id: '!self',
neighbor_id: '!ally',
neighbor_short_name: 'ALLY',
snr: 5.25,
neighbor: { short_name: 'ALLY', role: 'REPEATER' },
},
];
const html = renderNeighborGroups(
node,
neighbors,
(short, role) => `<span class="badge" data-role="${role}">${short}</span>`,
);
assert.equal(html.includes('Neighbors'), true);
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
assert.equal(html.includes('9.5 dB'), true);
assert.equal(html.includes('5.3 dB'), true);
assert.equal(html.includes('data-role="ROUTER"'), true);
assert.equal(html.includes('data-role="REPEATER"'), true);
});
test('buildNeighborRoleIndex fetches missing neighbor metadata from the API', async () => {
const neighbors = [
{ neighbor_id: '!ally', neighbor_short_name: 'ALLY' },
];
const calls = [];
const fetchImpl = async url => {
calls.push(url);
return {
status: 200,
ok: true,
json: async () => ({ node_id: '!ally', role: 'ROUTER', node_num: 99, short_name: 'ALLY-API' }),
};
};
const index = await buildNeighborRoleIndex({ nodeId: '!self', role: 'CLIENT' }, neighbors, { fetchImpl });
assert.equal(index.byId.get('!self'), 'CLIENT');
assert.equal(index.byId.get('!ally'), 'ROUTER');
assert.equal(index.byNum.get(99), 'ROUTER');
assert.equal(calls.some(url => url.startsWith('/api/nodes/')), true);
const allyMetadata = lookupNeighborDetails(index, { identifier: '!ally', numericId: 99 });
assert.equal(allyMetadata.shortName, 'ALLY-API');
});
test('renderSingleNodeTable renders a condensed table for the node', () => {
const node = {
shortName: 'NODE',
longName: 'Example Node',
nodeId: '!abcd',
role: 'CLIENT',
hwModel: 'T-Beam',
battery: 66,
voltage: 4.12,
uptime: 3_700,
channel_utilization: 1.23,
airUtil: 0.45,
temperature: 22.5,
humidity: 55.5,
pressure: 1_013.2,
latitude: 52.52,
longitude: 13.405,
altitude: 40,
lastHeard: 9_900,
positionTime: 9_850,
rawSources: { node: { node_id: '!abcd', role: 'CLIENT' } },
};
const html = renderSingleNodeTable(
node,
(short, role) => `<span class="short-name" data-role="${role}">${short}</span>`,
10_000,
);
assert.equal(html.includes('<table'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.equal(html.includes('66.0%'), true);
assert.equal(html.includes('1.230%'), true);
assert.equal(html.includes('52.52000'), true);
assert.equal(html.includes('1m 40s'), true);
assert.equal(html.includes('2m 30s'), true);
});
test('renderTelemetryCharts renders condensed scatter charts when telemetry exists', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
battery_level: 80,
voltage: 4.1,
channel_utilization: 40,
air_util_tx: 22,
},
environment_metrics: {
temperature: 19.5,
relative_humidity: 55,
barometric_pressure: 995,
gas_resistance: 1500,
},
},
{
rx_time: nowSeconds - 3_600,
deviceMetrics: {
batteryLevel: 78,
voltage: 4.05,
channelUtilization: 35,
airUtilTx: 20,
},
environmentMetrics: {
temperature: 18.4,
relativeHumidity: 52,
barometricPressure: 1000,
gasResistance: 2000,
},
},
],
},
},
};
const html = renderTelemetryCharts(node, { nowMs });
const fmt = new Date(nowMs);
const expectedDate = String(fmt.getDate()).padStart(2, '0');
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Environmental telemetry'), true);
assert.equal(html.includes('Battery (0-100%)'), true);
assert.equal(html.includes('Voltage (0-6V)'), true);
assert.equal(html.includes('Channel utilization (%)'), true);
assert.equal(html.includes('Air util TX (%)'), true);
assert.equal(html.includes('Utilization (%)'), true);
assert.equal(html.includes('Gas resistance (10-100k Ω)'), true);
assert.equal(html.includes('Temperature (-20-40°C)'), true);
assert.equal(html.includes(expectedDate), true);
assert.equal(html.includes('node-detail__chart-point'), true);
});
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
const html = renderNodeDetailHtml(
{
shortName: 'NODE',
longName: 'Example Node',
nodeId: '!abcd',
nodeNum: 77,
role: 'CLIENT',
battery: 60,
voltage: 4.1,
uptime: 1_000,
latitude: 52.5,
longitude: 13.4,
altitude: 40,
},
{
neighbors: [
{ node_id: '!peer', node_short_name: 'PEER', neighbor_id: '!abcd', snr: 7.5 },
{ node_id: '!abcd', neighbor_id: '!ally', neighbor_short_name: 'ALLY', snr: 5.1 },
],
messages: [{ text: 'Hello', rx_time: 1_700_000_111 }],
renderShortHtml: (short, role) => `<span class="short-name" data-role="${role}">${short}</span>`,
},
);
assert.equal(html.includes('node-detail__table'), true);
assert.equal(html.includes('Neighbors'), true);
assert.equal(html.includes('Heard by'), true);
assert.equal(html.includes('We hear'), true);
assert.equal(html.includes('Messages'), true);
assert.match(html, /<a class="node-long-link" href="\/nodes\/!abcd" data-node-detail-link="true" data-node-id="!abcd">Example Node<\/a>/);
assert.equal(html.includes('PEER'), true);
assert.equal(html.includes('ALLY'), true);
assert.match(html, /\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}\]\[/);
assert.equal(html.includes('data-role="CLIENT"'), true);
});
test('renderNodeDetailHtml embeds telemetry charts when snapshots are present', () => {
const nowMs = Date.UTC(2025, 0, 8, 7, 0, 0);
const node = {
shortName: 'NODE',
nodeId: '!abcd',
role: 'CLIENT',
rawSources: {
node: { node_id: '!abcd', role: 'CLIENT', short_name: 'NODE' },
telemetry: {
snapshots: [
{
rx_time: Math.floor(nowMs / 1000) - 120,
battery_level: 75,
voltage: 4.08,
channel_utilization: 30,
temperature: 20,
relative_humidity: 45,
barometric_pressure: 990,
gas_resistance: 1800,
},
],
},
},
};
const html = renderNodeDetailHtml(node, {
renderShortHtml: short => `<span class="short-name">${short}</span>`,
chartNowMs: nowMs,
});
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
});
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
const reference = { nodeId: '!alpha' };
let fetchCalls = 0;
const fetchImpl = async url => {
fetchCalls += 1;
assert.match(url, /\/api\/messages\/!alpha/);
return {
ok: true,
status: 200,
async json() {
return [{ text: 'Overlay hello', rx_time: 1_700_000_000 }];
},
};
};
const refreshImpl = async () => ({
nodeId: '!alpha',
nodeNum: 1,
shortName: 'ALPH',
longName: 'Example Alpha',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
});
const html = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
});
assert.equal(fetchCalls, 1);
assert.equal(html.includes('Example Alpha'), true);
assert.equal(html.includes('Overlay hello'), true);
assert.equal(html.includes('node-detail__table'), true);
});
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
await assert.rejects(
() => fetchNodeDetailHtml({}, { refreshImpl: async () => ({}) }),
/identifier/i,
);
});
test('parseReferencePayload returns null for invalid JSON', () => {
assert.equal(parseReferencePayload('{'), null);
assert.deepEqual(parseReferencePayload('{"nodeId":"!abc"}'), { nodeId: '!abc' });
});
test('resolveRenderShortHtml prefers global implementation when available', async () => {
const original = globalThis.PotatoMesh;
try {
globalThis.PotatoMesh = { renderShortHtml: () => '<span>ok</span>' };
const fn = await resolveRenderShortHtml();
assert.equal(fn('X'), '<span>ok</span>');
} finally {
globalThis.PotatoMesh = original;
}
});
test('resolveRenderShortHtml falls back when no implementation is exposed', async () => {
const original = globalThis.PotatoMesh;
try {
delete globalThis.PotatoMesh;
const fn = await resolveRenderShortHtml();
assert.equal(typeof fn, 'function');
assert.equal(fn('AB'), '<span class="short-name">AB</span>');
} finally {
globalThis.PotatoMesh = original;
}
});
test('fetchMessages handles HTTP responses and uses defaults', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return {
status: 200,
ok: true,
json: async () => [{ text: 'hi', rx_time: 1 }],
};
};
const messages = await fetchMessages('!node', { fetchImpl });
assert.equal(messages.length, 1);
assert.equal(calls[0].options.cache, 'no-store');
});
test('fetchMessages returns an empty list when the endpoint is missing', async () => {
const fetchImpl = async () => ({ status: 404, ok: false, json: async () => ({}) });
const messages = await fetchMessages('!node', { fetchImpl });
assert.deepEqual(messages, []);
});
test('initializeNodeDetailPage hydrates the container with node data', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const documentStub = {
querySelector: selector => (selector === '#nodeDetail' ? element : null),
};
const refreshImpl = async reference => {
assert.equal(reference.nodeId, '!node');
return {
shortName: 'NODE',
longName: 'Node Long',
nodeId: '!node',
role: 'CLIENT',
modemPreset: 'LongFast',
loraFreq: 915,
battery: 66,
voltage: 4.1,
uptime: 100,
latitude: 52.5,
longitude: 13.4,
altitude: 42,
neighbors: [{ node_id: '!node', neighbor_id: '!ally', snr: 5.5 }],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
};
};
const fetchImpl = async url => {
if (url.startsWith('/api/messages/')) {
return {
status: 200,
ok: true,
json: async () => [{ text: 'hello', rx_time: 1_700_000_222 }],
};
}
if (url.startsWith('/api/nodes/')) {
return {
status: 200,
ok: true,
json: async () => ({ node_id: '!ally', role: 'ROUTER', short_name: 'ALLY-API' }),
};
}
return { status: 404, ok: false, json: async () => ({}) };
};
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(element.innerHTML.includes('Node Long'), true);
assert.equal(element.innerHTML.includes('node-detail__table'), true);
assert.equal(element.innerHTML.includes('Neighbors'), true);
assert.equal(element.innerHTML.includes('Messages'), true);
assert.equal(element.innerHTML.includes('ALLY-API'), true);
});
test('initializeNodeDetailPage removes legacy filter controls when supported', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const filterContainer = {
removed: false,
remove() {
this.removed = true;
},
};
const documentStub = {
querySelector: selector => {
if (selector === '#nodeDetail') return element;
if (selector === '.filter-input') return filterContainer;
return null;
},
};
const refreshImpl = async () => ({
shortName: 'NODE',
nodeId: '!node',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
});
const fetchImpl = async () => ({ status: 404, ok: false });
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(filterContainer.removed, true);
});
test('initializeNodeDetailPage hides legacy filter controls when removal is unavailable', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!node', fallback: { short_name: 'NODE' } }),
privateMode: 'false',
},
innerHTML: '',
};
const filterContainer = { hidden: false };
const documentStub = {
querySelector: selector => {
if (selector === '#nodeDetail') return element;
if (selector === '.filter-input') return filterContainer;
return null;
},
};
const refreshImpl = async () => ({
shortName: 'NODE',
nodeId: '!node',
role: 'CLIENT',
neighbors: [],
rawSources: { node: { node_id: '!node', role: 'CLIENT' } },
});
const fetchImpl = async () => ({ status: 404, ok: false });
const renderShortHtml = short => `<span class="short-name">${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
fetchImpl,
renderShortHtml,
});
assert.equal(result, true);
assert.equal(filterContainer.hidden, true);
});
test('initializeNodeDetailPage reports an error when refresh fails', async () => {
const element = {
dataset: {
nodeReference: JSON.stringify({ nodeId: '!missing' }),
privateMode: 'false',
},
innerHTML: '',
};
const documentStub = { querySelector: () => element };
const refreshImpl = async () => {
throw new Error('boom');
};
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
});
test('initializeNodeDetailPage handles missing reference payloads', async () => {
const element = {
dataset: {},
innerHTML: '',
};
const documentStub = { querySelector: () => element };
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({ document: documentStub, renderShortHtml });
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Node reference unavailable'), true);
});
@@ -0,0 +1,73 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeNodeSnapshot, normalizeNodeCollection, __testUtils } from '../node-snapshot-normalizer.js';
const { normalizeNumber, normalizeString } = __testUtils;
test('normalizeNodeSnapshot synchronises telemetry aliases', () => {
const node = {
node_id: '!test',
channel: '56.2',
airUtil: 13.5,
battery_level: 45.5,
relativeHumidity: 24.3,
lastHeard: '1234',
};
const normalised = normalizeNodeSnapshot(node);
assert.equal(normalised.nodeId, '!test');
assert.equal(normalised.channel_utilization, 56.2);
assert.equal(normalised.channelUtilization, 56.2);
assert.equal(normalised.channel, 56.2);
assert.equal(normalised.air_util_tx, 13.5);
assert.equal(normalised.airUtilTx, 13.5);
assert.equal(normalised.airUtil, 13.5);
assert.equal(normalised.battery, 45.5);
assert.equal(normalised.batteryLevel, 45.5);
assert.equal(normalised.relative_humidity, 24.3);
assert.equal(normalised.humidity, 24.3);
assert.equal(normalised.last_heard, 1234);
});
test('normalizeNodeCollection applies canonical forms to all nodes', () => {
const nodes = [
{ short_name: ' AAA ', voltage: '3.7' },
{ shortName: 'BBB', uptime_seconds: '3600', airUtilTx: '5.5' },
];
normalizeNodeCollection(nodes);
assert.equal(nodes[0].shortName, 'AAA');
assert.equal(nodes[0].short_name, 'AAA');
assert.equal(nodes[0].voltage, 3.7);
assert.equal(nodes[1].uptime, 3600);
assert.equal(nodes[1].air_util_tx, 5.5);
});
test('normaliser helpers coerce primitive values consistently', () => {
assert.equal(normalizeNumber('42.1'), 42.1);
assert.equal(normalizeNumber('not-a-number'), null);
assert.equal(normalizeNumber(Infinity), null);
assert.equal(normalizeString(' hello '), 'hello');
assert.equal(normalizeString(''), null);
assert.equal(normalizeString(null), null);
});
@@ -0,0 +1,119 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { enhanceCoordinateCell, __testUtils } from '../nodes-coordinate-links.js';
const { toFiniteCoordinate } = __testUtils;
test('enhanceCoordinateCell renders an interactive link for valid coordinates', () => {
const cell = {
replacedChildren: null,
replaceChildren(...children) {
this.replacedChildren = children;
}
};
const linkStub = {
dataset: {},
attributes: new Map(),
listeners: new Map(),
href: null,
setAttribute(name, value) {
this.attributes.set(name, value);
},
addEventListener(name, handler) {
this.listeners.set(name, handler);
}
};
const documentStub = {
createElement(tagName) {
assert.equal(tagName, 'a');
return linkStub;
}
};
const activations = [];
const link = enhanceCoordinateCell({
cell,
document: documentStub,
displayText: '51.50000',
formattedLatitude: '51.50000',
formattedLongitude: '-0.12000',
lat: '51.5',
lon: '-0.12',
nodeName: 'Alpha',
onActivate: (lat, lon) => activations.push({ lat, lon })
});
assert.equal(link, linkStub);
assert.deepEqual(cell.replacedChildren, [linkStub]);
assert.equal(linkStub.textContent, '51.50000');
assert.equal(linkStub.dataset.lat, '51.5');
assert.equal(linkStub.dataset.lon, '-0.12');
assert.equal(linkStub.className, 'nodes-coordinate-link');
assert.equal(linkStub.attributes.get('aria-label'), 'Center map on Alpha at 51.50000, -0.12000');
assert.equal(linkStub.attributes.get('href'), '#');
const clickHandler = linkStub.listeners.get('click');
assert.equal(typeof clickHandler, 'function');
const event = {
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopPropagation() {
this.stopped = true;
}
};
clickHandler(event);
assert.equal(event.prevented, true);
assert.equal(event.stopped, true);
assert.deepEqual(activations, [{ lat: 51.5, lon: -0.12 }]);
});
test('enhanceCoordinateCell ignores invalid input data', () => {
const cell = {
replaceChildren() {
assert.fail('replaceChildren should not be called for invalid data');
}
};
const resultEmpty = enhanceCoordinateCell({
cell,
document: {},
displayText: '',
lat: 0,
lon: 0
});
assert.equal(resultEmpty, null);
const resultInvalid = enhanceCoordinateCell({
cell,
document: {},
displayText: 'value',
lat: 'north',
lon: 5
});
assert.equal(resultInvalid, null);
});
test('toFiniteCoordinate returns finite numbers and rejects NaN', () => {
assert.equal(toFiniteCoordinate('12.34'), 12.34);
assert.equal(toFiniteCoordinate(56.78), 56.78);
assert.equal(toFiniteCoordinate('NaN'), null);
assert.equal(toFiniteCoordinate(undefined), null);
});
@@ -0,0 +1,112 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM, __testUtils } from '../nodes-map-focus.js';
const { toFiniteCoordinate } = __testUtils;
test('createMapFocusHandler recentres the map using Leaflet setView', () => {
let interactions = 0;
const autoFitController = {
handleUserInteraction() {
interactions += 1;
}
};
const map = {
calls: [],
setView(target, zoom, options) {
this.calls.push({ target, zoom, options });
}
};
const centers = [];
const handler = createMapFocusHandler({
getMap: () => map,
autoFitController,
leaflet: {
latLng(lat, lon) {
return { lat, lng: lon, source: 'leaflet' };
}
},
defaultZoom: 11,
setMapCenter: value => centers.push(value)
});
const result = handler('51.5', '-0.12');
assert.equal(result, true);
assert.equal(interactions, 1);
assert.equal(map.calls.length, 1);
assert.deepEqual(map.calls[0], { target: [51.5, -0.12], zoom: 11, options: { animate: true } });
assert.deepEqual(centers, [{ lat: 51.5, lng: -0.12, source: 'leaflet' }]);
});
test('createMapFocusHandler supports panTo fallback and numeric centres', () => {
const panCalls = [];
const zoomCalls = [];
const map = {
panTo(target, options) {
panCalls.push({ target, options });
},
setZoom(value) {
zoomCalls.push(value);
}
};
const centers = [];
const handler = createMapFocusHandler({
getMap: () => map,
leaflet: {
latLng() {
throw new Error('Leaflet latLng unavailable');
}
},
defaultZoom: DEFAULT_NODE_FOCUS_ZOOM,
setMapCenter: value => centers.push(value)
});
const result = handler(40.7128, -74.006, { zoom: 9, animate: false });
assert.equal(result, true);
assert.deepEqual(panCalls, [{ target: [40.7128, -74.006], options: { animate: false } }]);
assert.deepEqual(zoomCalls, [9]);
assert.deepEqual(centers, [{ lat: 40.7128, lon: -74.006 }]);
});
test('createMapFocusHandler validates inputs and map availability', () => {
assert.throws(() => {
createMapFocusHandler({ getMap: null });
}, /getMap/);
const missingMapHandler = createMapFocusHandler({ getMap: () => null });
assert.equal(missingMapHandler(10, 20), false);
const map = {
setView() {}
};
const handler = createMapFocusHandler({ getMap: () => map });
assert.equal(handler(null, 2), false);
assert.equal(handler(1, undefined), false);
assert.equal(handler(1, 2, { zoom: -5 }), false);
});
test('toFiniteCoordinate converts valid strings and rejects invalid values', () => {
assert.equal(toFiniteCoordinate('42.5'), 42.5);
assert.equal(toFiniteCoordinate(19), 19);
assert.equal(toFiniteCoordinate('abc'), null);
assert.equal(toFiniteCoordinate(null), null);
});
@@ -89,7 +89,7 @@ test('collectTelemetryMetrics prefers latest nested telemetry values over stale
air_util_tx: 0.0091,
},
telemetry: {
channel: 0.563,
channel_utilization: 0.563,
},
raw: {
device_metrics: {
@@ -0,0 +1,121 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SNAPSHOT_WINDOW,
aggregateSnapshots,
aggregateNodeSnapshots,
aggregateTelemetrySnapshots,
aggregatePositionSnapshots,
aggregateNeighborSnapshots,
} from '../snapshot-aggregator.js';
const SAMPLE_NODE_ID = '!node';
function keyById(entry) {
return entry && typeof entry.id === 'string' ? entry.id : null;
}
test('aggregateSnapshots merges snapshots in chronological order', () => {
const snapshots = [
{ id: 'alpha', metric: null, label: 'latest', ts: 30, ignored: Number.NaN },
{ id: 'alpha', metric: 5, label: null, ts: 20 },
{ id: 'alpha', metric: 1, legacy: 'keep', ts: 10 },
];
const aggregated = aggregateSnapshots(snapshots, { keySelector: keyById, limit: 3 });
assert.equal(aggregated.length, 1);
const record = aggregated[0];
assert.equal(record.metric, 5);
assert.equal(record.label, 'latest');
assert.equal(record.legacy, 'keep');
assert.deepEqual(record.snapshots.map(s => s.ts), [10, 20, 30]);
assert.equal(record.latestSnapshot.ts, 30);
assert.equal(Object.prototype.propertyIsEnumerable.call(record, 'snapshots'), false);
assert.equal(Object.prototype.propertyIsEnumerable.call(record, 'latestSnapshot'), false);
assert.equal('ignored' in record, false);
});
test('aggregateSnapshots enforces key selectors and respects limits', () => {
assert.throws(() => aggregateSnapshots([{ id: 'noop' }], {}), /keySelector/);
const snapshots = [
{ id: 'beta', value: 'newest', ts: 30 },
{ id: 'beta', value: 'mid', ts: 20 },
{ id: 'beta', value: 'oldest', ts: 10 },
];
const aggregated = aggregateSnapshots(snapshots, { keySelector: keyById, limit: 2 });
assert.equal(aggregated[0].snapshots.length, 2);
assert.deepEqual(aggregated[0].snapshots.map(s => s.ts), [20, 30]);
});
test('aggregateNodeSnapshots reconciles identifiers and fills missing values', () => {
const entries = [
{ nodeId: SAMPLE_NODE_ID, voltage: 4.2, battery_level: null, rx_time: 250 },
{ node_id: SAMPLE_NODE_ID, node_num: 42, battery_level: 20, rx_time: 200 },
{ node_num: 42, short_name: 'Legacy', battery_level: 15, rx_time: 100 },
];
const aggregated = aggregateNodeSnapshots(entries, { limit: SNAPSHOT_WINDOW });
assert.equal(aggregated.length, 1);
const node = aggregated[0];
assert.equal(node.node_id, SAMPLE_NODE_ID);
assert.equal(node.node_num, 42);
assert.equal(node.short_name, 'Legacy');
assert.equal(node.battery_level, 20);
assert.equal(node.voltage, 4.2);
assert.equal(node.snapshots.length, 3);
});
test('aggregateTelemetrySnapshots and aggregatePositionSnapshots mirror node aggregation', () => {
const telemetryEntries = [
{ node_id: SAMPLE_NODE_ID, node_num: 5, temperature: null, rx_time: 20 },
{ node_num: 5, temperature: 21.5, humidity: 52, rx_time: 10 },
];
const positionEntries = [
{ node_id: SAMPLE_NODE_ID, node_num: 5, longitude: 13.4, rx_time: 25 },
{ node_num: 5, latitude: 52.5, rx_time: 15 },
];
const telemetryAggregated = aggregateTelemetrySnapshots(telemetryEntries, { limit: 3 });
const positionAggregated = aggregatePositionSnapshots(positionEntries, { limit: 3 });
assert.equal(telemetryAggregated.length, 1);
assert.equal(positionAggregated.length, 1);
assert.equal(telemetryAggregated[0].temperature, 21.5);
assert.equal(telemetryAggregated[0].humidity, 52);
assert.equal(positionAggregated[0].latitude, 52.5);
assert.equal(positionAggregated[0].longitude, 13.4);
});
test('aggregateNeighborSnapshots groups by node pairs', () => {
const neighborSnapshots = [
{ node_id: '!src', node_num: 101, neighbor_id: '!dst', neighbor_num: 202, snr: null, rx_time: 180 },
{ node_id: '!src', node_num: 101, neighbor_id: '!dst', neighbor_num: 202, snr: -5, rx_time: 150 },
{ node_num: 101, neighbor_num: 202, snr: -11, rx_time: 100 },
null,
];
const aggregated = aggregateNeighborSnapshots(neighborSnapshots, { limit: 5 });
assert.equal(aggregated.length, 1);
const connection = aggregated[0];
assert.equal(connection.node_id, '!src');
assert.equal(connection.neighbor_id, '!dst');
assert.equal(connection.snr, -5);
assert.equal(connection.snapshots.length, 3);
});
test('aggregateSnapshots returns an empty array when no entries are provided', () => {
assert.deepEqual(aggregateSnapshots(null, { keySelector: () => 'noop' }), []);
assert.deepEqual(aggregateNodeSnapshots([], {}), []);
});
+148
View File
@@ -0,0 +1,148 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderTelemetryCharts } from './node-page.js';
const TELEMETRY_AGGREGATE_LIMIT = 1000;
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderStatus(message, { error = false } = {}) {
const errorClass = error ? ' charts-page__status--error' : '';
return `<p class="charts-page__status${errorClass}">${escapeHtml(message)}</p>`;
}
function padTwo(value) {
const num = Number(value);
if (Number.isNaN(num)) return '00';
return num < 10 ? `0${Math.trunc(num)}` : String(Math.trunc(num));
}
function buildHourlyTickList(nowMs, windowMs = DAY_MS) {
const ticks = [];
const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DAY_MS;
const domainStart = nowMs - safeWindow;
const cursor = new Date(nowMs);
cursor.setMinutes(0, 0, 0);
for (let ts = cursor.getTime(); ts >= domainStart; ts -= HOUR_MS) {
ticks.push(ts);
}
return ticks.reverse();
}
function formatHourLabel(timestampMs) {
const date = new Date(timestampMs);
if (Number.isNaN(date.getTime())) return '';
return padTwo(date.getHours());
}
export function buildMovingAverageSeries(points, windowMs = HOUR_MS) {
if (!Array.isArray(points) || points.length === 0) {
return [];
}
const safeWindow = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : HOUR_MS;
const window = [];
let sum = 0;
const averages = [];
for (const point of points) {
if (!point || typeof point.timestamp !== 'number' || typeof point.value !== 'number') {
continue;
}
window.push(point);
sum += point.value;
while (window.length && point.timestamp - window[0].timestamp > safeWindow) {
const removed = window.shift();
sum -= removed.value;
}
if (window.length > 0) {
averages.push({
timestamp: point.timestamp,
value: sum / window.length,
});
}
}
return averages;
}
export async function fetchAggregatedTelemetry({ fetchImpl = globalThis.fetch, limit = TELEMETRY_AGGREGATE_LIMIT } = {}) {
const fetchFn = typeof fetchImpl === 'function' ? fetchImpl : null;
if (!fetchFn) {
throw new TypeError('A fetch implementation is required to load telemetry');
}
const effectiveLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TELEMETRY_AGGREGATE_LIMIT;
const response = await fetchFn(`/api/telemetry?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`Failed to fetch telemetry (HTTP ${response.status})`);
}
const payload = await response.json();
return Array.isArray(payload) ? payload : [];
}
export async function initializeChartsPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.getElementById !== 'function') {
throw new TypeError('A document with getElementById support is required');
}
const rootId = options.rootId ?? 'chartsPage';
const container = documentRef.getElementById(rootId);
if (!container) {
return false;
}
const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
const limit = options.limit ?? TELEMETRY_AGGREGATE_LIMIT;
container.innerHTML = renderStatus('Loading aggregated telemetry charts…');
try {
const snapshots = await fetchAggregatedTelemetry({ fetchImpl, limit });
if (!Array.isArray(snapshots) || snapshots.length === 0) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
return true;
}
const node = { rawSources: { telemetry: { snapshots } } };
const chartsHtml = renderCharts(node, {
nowMs: Date.now(),
chartOptions: {
windowMs: DAY_MS,
timeRangeLabel: 'Last 24 hours',
xAxisTickBuilder: buildHourlyTickList,
xAxisTickFormatter: formatHourLabel,
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
},
});
if (!chartsHtml) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
return true;
}
container.innerHTML = chartsHtml;
return true;
} catch (error) {
console.error('Failed to render aggregated telemetry charts', error);
container.innerHTML = renderStatus('Failed to load telemetry charts.', { error: true });
return false;
}
}
+51 -25
View File
@@ -42,6 +42,23 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
MESSAGE_ENCRYPTED: 'message-encrypted'
});
/**
* Resolve the chronological snapshots associated with an aggregated entry.
*
* @param {*} entry Candidate snapshot or aggregate.
* @returns {Array<Object>} Chronologically ordered snapshots.
*/
function resolveSnapshotList(entry) {
if (!entry || typeof entry !== 'object') {
return [];
}
const snapshots = entry.snapshots;
if (Array.isArray(snapshots) && snapshots.length > 0) {
return snapshots;
}
return [entry];
}
/**
* Build a data model describing the content for chat tabs.
*
@@ -97,37 +114,46 @@ export function buildChatTabModel({
}
for (const telemetryEntry of telemetry || []) {
if (!telemetryEntry) continue;
const ts = resolveTimestampSeconds(
telemetryEntry.rx_time ?? telemetryEntry.rxTime ?? telemetryEntry.telemetry_time ?? telemetryEntry.telemetryTime,
telemetryEntry.rx_iso ?? telemetryEntry.rxIso ?? telemetryEntry.telemetry_time_iso ?? telemetryEntry.telemetryTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(telemetryEntry);
const nodeNum = normaliseNodeNum(telemetryEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.TELEMETRY, telemetry: telemetryEntry, nodeId, nodeNum });
const snapshots = resolveSnapshotList(telemetryEntry);
for (const snapshot of snapshots) {
if (!snapshot) continue;
const ts = resolveTimestampSeconds(
snapshot.rx_time ?? snapshot.rxTime ?? snapshot.telemetry_time ?? snapshot.telemetryTime,
snapshot.rx_iso ?? snapshot.rxIso ?? snapshot.telemetry_time_iso ?? snapshot.telemetryTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(snapshot);
const nodeNum = normaliseNodeNum(snapshot);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.TELEMETRY, telemetry: snapshot, nodeId, nodeNum });
}
}
for (const positionEntry of positions || []) {
if (!positionEntry) continue;
const ts = resolveTimestampSeconds(
positionEntry.rx_time ?? positionEntry.rxTime ?? positionEntry.position_time ?? positionEntry.positionTime,
positionEntry.rx_iso ?? positionEntry.rxIso ?? positionEntry.position_time_iso ?? positionEntry.positionTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(positionEntry);
const nodeNum = normaliseNodeNum(positionEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.POSITION, position: positionEntry, nodeId, nodeNum });
const snapshots = resolveSnapshotList(positionEntry);
for (const snapshot of snapshots) {
if (!snapshot) continue;
const ts = resolveTimestampSeconds(
snapshot.rx_time ?? snapshot.rxTime ?? snapshot.position_time ?? snapshot.positionTime,
snapshot.rx_iso ?? snapshot.rxIso ?? snapshot.position_time_iso ?? snapshot.positionTimeIso
);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(snapshot);
const nodeNum = normaliseNodeNum(snapshot);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.POSITION, position: snapshot, nodeId, nodeNum });
}
}
for (const neighborEntry of neighbors || []) {
if (!neighborEntry) continue;
const ts = resolveTimestampSeconds(neighborEntry.rx_time ?? neighborEntry.rxTime, neighborEntry.rx_iso ?? neighborEntry.rxIso);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(neighborEntry);
const nodeNum = normaliseNodeNum(neighborEntry);
const neighborId = normaliseNeighborId(neighborEntry);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: neighborEntry, nodeId, nodeNum, neighborId });
const snapshots = resolveSnapshotList(neighborEntry);
for (const snapshot of snapshots) {
if (!snapshot) continue;
const ts = resolveTimestampSeconds(snapshot.rx_time ?? snapshot.rxTime, snapshot.rx_iso ?? snapshot.rxIso);
if (ts == null || ts < cutoff) continue;
const nodeId = normaliseNodeId(snapshot);
const nodeNum = normaliseNodeNum(snapshot);
const neighborId = normaliseNeighborId(snapshot);
logEntries.push({ ts, type: CHAT_LOG_ENTRY_TYPES.NEIGHBOR, neighbor: snapshot, nodeId, nodeNum, neighborId });
}
}
const encryptedLogEntries = [];
+287 -28
View File
@@ -18,7 +18,10 @@ import { computeBoundingBox, computeBoundsForPoints, haversineDistanceKm } from
import { createMapAutoFitController } from './map-auto-fit-controller.js';
import { resolveAutoFitBoundsConfig } from './map-auto-fit-settings.js';
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
import { createMapFocusHandler, DEFAULT_NODE_FOCUS_ZOOM } from './nodes-map-focus.js';
import { enhanceCoordinateCell } from './nodes-coordinate-links.js';
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { createNodeDetailOverlayManager } from './node-detail-overlay.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import {
@@ -45,6 +48,14 @@ import { renderChatTabs } from './chat-tabs.js';
import { formatPositionHighlights, formatTelemetryHighlights } from './chat-log-highlights.js';
import { filterChatModel, normaliseChatFilterQuery } from './chat-search.js';
import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './message-replies.js';
import {
SNAPSHOT_WINDOW,
aggregateNeighborSnapshots,
aggregateNodeSnapshots,
aggregatePositionSnapshots,
aggregateTelemetrySnapshots,
} from './snapshot-aggregator.js';
import { normalizeNodeCollection } from './node-snapshot-normalizer.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -57,6 +68,7 @@ import { buildMessageBody, buildMessageIndex, resolveReplyPrefix } from './messa
* channel: string,
* frequency: string,
* mapCenter: { lat: number, lon: number },
* mapZoom: number | null,
* maxDistanceKm: number,
* tileFilters: { light: string, dark: string }
* }} config Normalized application configuration.
@@ -89,8 +101,12 @@ export function initializeApp(config) {
? { parent: infoOverlay.parentNode, nextSibling: infoOverlay.nextSibling }
: null;
const bodyClassList = document.body ? document.body.classList : null;
const isPrivateMode = document.body && document.body.dataset
? String(document.body.dataset.privateMode).toLowerCase() === 'true'
: false;
const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false;
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
/**
* Column sorter configuration for the node table.
*
@@ -149,6 +165,7 @@ let messagesById = new Map();
logger: console,
});
const NODE_LIMIT = 1000;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
const REFRESH_MS = config.refreshMs;
@@ -379,6 +396,12 @@ let messagesById = new Map();
}
}
if (fitBoundsEl && mapZoomOverride !== null) {
fitBoundsEl.checked = false;
fitBoundsEl.disabled = true;
fitBoundsEl.setAttribute('aria-disabled', 'true');
}
const MAP_CENTER_COORDS = Object.freeze({ lat: config.mapCenter.lat, lon: config.mapCenter.lon });
const hasLeaflet = typeof window !== 'undefined' && typeof window.L === 'object' && window.L && typeof window.L.map === 'function';
const mapContainer = document.getElementById('map');
@@ -421,6 +444,16 @@ let messagesById = new Map();
defaultPaddingPx: AUTO_FIT_PADDING_PX
});
const focusMapOnCoordinates = createMapFocusHandler({
getMap: () => map,
autoFitController,
leaflet: hasLeaflet ? window.L : null,
defaultZoom: DEFAULT_NODE_FOCUS_ZOOM,
setMapCenter: value => {
mapCenterLatLng = value;
}
});
/**
* Fit the Leaflet map to the provided geographic bounds.
*
@@ -1227,7 +1260,9 @@ let messagesById = new Map();
LIMIT_DISTANCE ? MAX_DISTANCE_KM : null,
{ minimumRangeKm: 1 }
);
if (initialBounds) {
if (mapZoomOverride !== null) {
map.setView([MAP_CENTER_COORDS.lat, MAP_CENTER_COORDS.lon], mapZoomOverride);
} else if (initialBounds) {
fitMapToBounds(initialBounds, { animate: false, paddingPx: INITIAL_VIEW_PADDING_PX, maxZoom: MAX_INITIAL_ZOOM });
} else if (mapCenterLatLng) {
map.setView(mapCenterLatLng, 10);
@@ -1566,7 +1601,31 @@ let messagesById = new Map();
});
}
const nodeDetailOverlayManager = createNodeDetailOverlayManager({
document,
privateMode: isPrivateMode,
});
document.addEventListener('click', event => {
const longNameLink = event.target.closest('.node-long-link');
if (
longNameLink &&
nodeDetailOverlayManager &&
shouldHandleNodeLongLink(longNameLink) &&
!(event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
) {
const identifier = getNodeIdentifierFromLink(longNameLink);
if (identifier) {
event.preventDefault();
event.stopPropagation();
overlayStack.closeAll();
const label = typeof longNameLink.textContent === 'string' ? longNameLink.textContent.trim() : '';
nodeDetailOverlayManager.open({ nodeId: identifier }, { trigger: longNameLink, label })
.catch(err => console.error('Failed to open node detail overlay', err));
return;
}
}
const shortTarget = event.target.closest('.short-name');
if (
shortTarget &&
@@ -1729,6 +1788,12 @@ let messagesById = new Map();
return `<span class="short-name" style="background:${color}"${titleAttr}${infoAttr}>${padded}</span>`;
}
const potatoMeshNamespace = globalThis.PotatoMesh || (globalThis.PotatoMesh = {});
potatoMeshNamespace.renderShortHtml = renderShortHtml;
potatoMeshNamespace.getRoleColor = getRoleColor;
potatoMeshNamespace.getRoleKey = getRoleKey;
potatoMeshNamespace.normalizeRole = normalizeRole;
/**
* Escape a CSS selector fragment with a defensive fallback for
* environments lacking ``CSS.escape`` support.
@@ -1846,9 +1911,9 @@ let messagesById = new Map();
*/
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 longNameLink = renderNodeLongNameLink(node?.long_name, node?.node_id);
if (longNameLink) {
lines.push(`<b>${longNameLink}</b>`);
}
const shortHtml = renderShortHtml(node?.short_name, node?.role, node?.long_name, node);
@@ -2057,7 +2122,16 @@ let messagesById = new Map();
if (!target) return;
const normalized = normalizeOverlaySource(info || {});
const heading = normalized.longName || normalized.shortName || normalized.nodeId || '';
const headingHtml = heading ? `<strong>${escapeHtml(heading)}</strong><br/>` : '';
let headingHtml = '';
if (normalized.longName) {
const link = renderNodeLongNameLink(normalized.longName, normalized.nodeId);
if (link) {
headingHtml = `<strong>${link}</strong><br/>`;
}
}
if (!headingHtml && heading) {
headingHtml = `<strong>${escapeHtml(heading)}</strong><br/>`;
}
overlayStack.render(target, `${headingHtml}Loading…`);
}
@@ -2075,9 +2149,14 @@ let messagesById = new Map();
overlayInfo.role = 'CLIENT';
}
const lines = [];
const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? '');
if (longNameValue !== '—') {
lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
const longNameLink = renderNodeLongNameLink(overlayInfo.longName, overlayInfo.nodeId);
if (longNameLink) {
lines.push(`<strong>${longNameLink}</strong>`);
} else {
const longNameValue = shortInfoValueOrDash(overlayInfo.longName ?? '');
if (longNameValue !== '—') {
lines.push(`<strong>${escapeHtml(longNameValue)}</strong>`);
}
}
const shortParts = [];
const shortHtml = renderShortHtml(overlayInfo.shortName, overlayInfo.role, overlayInfo.longName);
@@ -2149,9 +2228,16 @@ let messagesById = new Map();
const sourceIdText = shortInfoValueOrDash(segment.sourceId || '');
const neighborFullName = shortInfoValueOrDash(segment.targetDisplayName || segment.targetId || '');
const lines = [];
lines.push(`<strong>${escapeHtml(nodeName)}</strong>`);
const sourceLongLink = renderNodeLongNameLink(segment.sourceDisplayName, segment.sourceId);
if (sourceLongLink) {
lines.push(`<strong>${sourceLongLink}</strong>`);
} else {
lines.push(`<strong>${escapeHtml(nodeName)}</strong>`);
}
lines.push(`${sourceShortHtml} <span class="mono">${escapeHtml(sourceIdText)}</span>`);
const neighborLine = `${targetShortHtml} [${escapeHtml(neighborFullName)}]`;
const neighborLongLink = renderNodeLongNameLink(segment.targetDisplayName, segment.targetId);
const neighborLabel = neighborLongLink || escapeHtml(neighborFullName);
const neighborLine = `${targetShortHtml} [${neighborLabel}]`;
lines.push(neighborLine);
lines.push(`SNR: ${escapeHtml(snrText)}`);
overlayStack.render(target, lines.join('<br/>'));
@@ -2194,6 +2280,8 @@ let messagesById = new Map();
const fallbackId = nodeIdRaw || 'Unknown node';
const longNameRaw = pickFirstProperty([node], ['long_name', 'longName']);
const longNameDisplay = longNameRaw ? String(longNameRaw) : fallbackId;
const longNameLink = renderNodeLongNameLink(longNameRaw, nodeIdRaw);
const announcementName = longNameLink || escapeHtml(longNameDisplay);
const shortNameRaw = pickFirstProperty([node], ['short_name', 'shortName']);
const shortNameDisplay = shortNameRaw ? String(shortNameRaw) : (nodeIdRaw ? nodeIdRaw.slice(-4) : null);
const roleDisplay = pickFirstProperty([node], ['role']);
@@ -2207,7 +2295,7 @@ let messagesById = new Map();
role: roleDisplay,
metadataSource: node,
nodeData: node,
messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy(`New node: ${longNameDisplay}`)}`
messageHtml: `${renderEmojiHtml('☀️')} ${renderAnnouncementCopy('New node:', ` ${announcementName}`)}`
});
}
@@ -2961,6 +3049,124 @@ let messagesById = new Map();
return str.length ? str : '';
}
/**
* Compute the node detail path for a given identifier.
*
* @param {string|null} identifier Node identifier.
* @returns {string|null} Detail path.
*/
function buildNodeDetailHref(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
const body = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed;
if (!body) return null;
const encoded = encodeURIComponent(body);
return `/nodes/!${encoded}`;
}
/**
* Ensure ``identifier`` includes the canonical ``!`` prefix.
*
* @param {*} identifier Candidate identifier.
* @returns {string|null} Canonical identifier or ``null``.
*/
function canonicalNodeIdentifier(identifier) {
if (identifier == null) return null;
const trimmed = String(identifier).trim();
if (!trimmed) return null;
return trimmed.startsWith('!') ? trimmed : `!${trimmed}`;
}
/**
* Render a linked long name pointing to the node detail view.
*
* @param {string|null} longName Display name.
* @param {string|null} identifier Node identifier.
* @param {string} [className='node-long-link'] Optional class attribute.
* @returns {string} Escaped HTML snippet.
*/
function renderNodeLongNameLink(longName, identifier, className = 'node-long-link') {
const text = normalizeNodeNameValue(longName);
if (!text) return '';
const href = buildNodeDetailHref(identifier);
if (!href) {
return escapeHtml(text);
}
const classAttr = className ? ` class="${escapeHtml(className)}"` : '';
const canonicalIdentifier = canonicalNodeIdentifier(identifier);
const dataAttrs = canonicalIdentifier
? ` data-node-detail-link="true" data-node-id="${escapeHtml(canonicalIdentifier)}"`
: ' data-node-detail-link="true"';
return `<a${classAttr} href="${href}"${dataAttrs}>${escapeHtml(text)}</a>`;
}
/**
* Determine whether a long name link should trigger the overlay behaviour.
*
* @param {?Element} link Anchor element.
* @returns {boolean} ``true`` when the link participates in overlays.
*/
function shouldHandleNodeLongLink(link) {
if (!link || !link.dataset) return false;
if ('nodeDetailLink' in link.dataset && link.dataset.nodeDetailLink === 'false') {
return false;
}
return true;
}
/**
* Extract the canonical node identifier from the provided link element.
*
* @param {?Element} link Anchor element.
* @returns {string} Canonical node identifier or ``''`` when unavailable.
*/
function getNodeIdentifierFromLink(link) {
if (!link) return '';
const datasetIdentifier = link.dataset && typeof link.dataset.nodeId === 'string'
? canonicalNodeIdentifier(link.dataset.nodeId)
: null;
if (datasetIdentifier) {
return datasetIdentifier;
}
if (typeof link.getAttribute === 'function') {
const attrHref = link.getAttribute('href');
const canonicalFromAttr = extractIdentifierFromHref(attrHref);
if (canonicalFromAttr) {
return canonicalFromAttr;
}
}
if (typeof link.href === 'string') {
const canonicalFromProperty = extractIdentifierFromHref(link.href);
if (canonicalFromProperty) {
return canonicalFromProperty;
}
}
return '';
}
/**
* Extract the canonical identifier from a node detail hyperlink.
*
* @param {string} href Link href attribute.
* @returns {string} Canonical identifier or ``''``.
*/
function extractIdentifierFromHref(href) {
if (typeof href !== 'string' || href.length === 0) {
return '';
}
const match = href.match(/\/nodes\/(![^/?#]+)/i);
if (!match || !match[1]) {
return '';
}
try {
const decoded = decodeURIComponent(match[1]);
return canonicalNodeIdentifier(decoded) ?? '';
} catch {
return canonicalNodeIdentifier(match[1]) ?? '';
}
}
/**
* Determine the preferred display name for overlay content.
*
@@ -3029,6 +3235,23 @@ let messagesById = new Map();
return `${Math.floor(diff/86400)}d ${Math.floor((diff%86400)/3600)}h`;
}
/**
* Determine how many snapshots should be requested from the API to build a
* richer aggregate.
*
* @param {number} requestedLimit Desired number of unique entities.
* @param {number} [maxLimit=NODE_LIMIT] Maximum rows accepted by the API.
* @returns {number} Effective request limit honouring {@link SNAPSHOT_LIMIT}.
*/
function resolveSnapshotLimit(requestedLimit, maxLimit = NODE_LIMIT) {
const base = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.floor(requestedLimit)
: maxLimit;
const expanded = base * SNAPSHOT_LIMIT;
const candidate = expanded > base ? expanded : base;
return Math.min(candidate, maxLimit);
}
/**
* Fetch the latest nodes from the JSON API.
*
@@ -3036,7 +3259,8 @@ let messagesById = new Map();
* @returns {Promise<Array<Object>>} Parsed node payloads.
*/
async function fetchNodes(limit = NODE_LIMIT) {
const r = await fetch(`/api/nodes?limit=${limit}`, { cache: 'no-store' });
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/nodes?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
@@ -3084,7 +3308,8 @@ let messagesById = new Map();
* @returns {Promise<Array<Object>>} Parsed neighbour payloads.
*/
async function fetchNeighbors(limit = NODE_LIMIT) {
const r = await fetch(`/api/neighbors?limit=${limit}`, { cache: 'no-store' });
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/neighbors?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
@@ -3096,7 +3321,8 @@ let messagesById = new Map();
* @returns {Promise<Array<Object>>} Parsed telemetry payloads.
*/
async function fetchTelemetry(limit = NODE_LIMIT) {
const r = await fetch(`/api/telemetry?limit=${limit}`, { cache: 'no-store' });
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/telemetry?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
@@ -3108,7 +3334,8 @@ let messagesById = new Map();
* @returns {Promise<Array<Object>>} Parsed position payloads.
*/
async function fetchPositions(limit = NODE_LIMIT) {
const r = await fetch(`/api/positions?limit=${limit}`, { cache: 'no-store' });
const effectiveLimit = resolveSnapshotLimit(limit, NODE_LIMIT);
const r = await fetch(`/api/positions?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
@@ -3300,7 +3527,7 @@ let messagesById = new Map();
* @returns {number|null} Distance in kilometres.
*/
function distanceFromCenterKm(lat, lon) {
if (hasLeaflet && mapCenterLatLng) {
if (hasLeaflet && mapCenterLatLng && typeof mapCenterLatLng.distanceTo === 'function') {
try {
return L.latLng(lat, lon).distanceTo(mapCenterLatLng) / 1000;
} catch (err) {
@@ -3352,10 +3579,14 @@ let messagesById = new Map();
const tr = document.createElement('tr');
const lastPositionTime = toFiniteNumber(n.position_time ?? n.positionTime);
const lastPositionCell = lastPositionTime != null ? timeAgo(lastPositionTime, nowSec) : '';
tr.innerHTML = `
const latitudeDisplay = fmtCoords(n.latitude);
const longitudeDisplay = fmtCoords(n.longitude);
const nodeDisplayName = getNodeDisplayNameForOverlay(n);
const longNameHtml = renderNodeLongNameLink(n.long_name, n.node_id);
tr.innerHTML = `
<td class="mono nodes-col nodes-col--node-id">${n.node_id || ""}</td>
<td class="nodes-col nodes-col--short-name">${renderShortHtml(n.short_name, n.role, n.long_name, n)}</td>
<td class="nodes-col nodes-col--long-name">${n.long_name || ""}</td>
<td class="nodes-col nodes-col--long-name">${longNameHtml}</td>
<td class="nodes-col nodes-col--last-seen">${timeAgo(n.last_heard, nowSec)}</td>
<td class="nodes-col nodes-col--role">${n.role || "CLIENT"}</td>
<td class="nodes-col nodes-col--hw-model">${fmtHw(n.hw_model)}</td>
@@ -3367,10 +3598,33 @@ let messagesById = new Map();
<td class="nodes-col nodes-col--temperature">${fmtTemperature(n.temperature)}</td>
<td class="nodes-col nodes-col--humidity">${fmtHumidity(n.relative_humidity)}</td>
<td class="nodes-col nodes-col--pressure">${fmtPressure(n.barometric_pressure)}</td>
<td class="nodes-col nodes-col--latitude">${fmtCoords(n.latitude)}</td>
<td class="nodes-col nodes-col--longitude">${fmtCoords(n.longitude)}</td>
<td class="nodes-col nodes-col--latitude">${latitudeDisplay}</td>
<td class="nodes-col nodes-col--longitude">${longitudeDisplay}</td>
<td class="nodes-col nodes-col--altitude">${fmtAlt(n.altitude, "m")}</td>
<td class="mono nodes-col nodes-col--last-position">${lastPositionCell}</td>`;
enhanceCoordinateCell({
cell: tr.querySelector('.nodes-col--latitude'),
document,
displayText: latitudeDisplay,
formattedLatitude: latitudeDisplay,
formattedLongitude: longitudeDisplay,
lat: n.latitude,
lon: n.longitude,
nodeName: nodeDisplayName,
onActivate: focusMapOnCoordinates
});
enhanceCoordinateCell({
cell: tr.querySelector('.nodes-col--longitude'),
document,
displayText: longitudeDisplay,
formattedLatitude: latitudeDisplay,
formattedLongitude: longitudeDisplay,
lat: n.latitude,
lon: n.longitude,
nodeName: nodeDisplayName,
onActivate: focusMapOnCoordinates
});
frag.appendChild(tr);
}
tb.replaceChildren(frag);
@@ -3719,11 +3973,16 @@ let messagesById = new Map();
telemetryPromise,
encryptedMessagesPromise
]);
nodes.forEach(applyNodeNameFallback);
mergePositionsIntoNodes(nodes, positions);
computeDistances(nodes);
mergeTelemetryIntoNodes(nodes, telemetryEntries);
allNodes = nodes;
const aggregatedNodes = aggregateNodeSnapshots(nodes);
const aggregatedPositions = aggregatePositionSnapshots(positions);
const aggregatedNeighbors = aggregateNeighborSnapshots(neighborTuples);
const aggregatedTelemetry = aggregateTelemetrySnapshots(telemetryEntries);
aggregatedNodes.forEach(applyNodeNameFallback);
mergePositionsIntoNodes(aggregatedNodes, aggregatedPositions);
computeDistances(aggregatedNodes);
mergeTelemetryIntoNodes(aggregatedNodes, aggregatedTelemetry);
normalizeNodeCollection(aggregatedNodes);
allNodes = aggregatedNodes;
rebuildNodeIndex(allNodes);
const [chatMessages, encryptedChatMessages] = await Promise.all([
messageNodeHydrator.hydrate(messages, nodesById),
@@ -3731,9 +3990,9 @@ let messagesById = new Map();
]);
allMessages = Array.isArray(chatMessages) ? chatMessages : [];
allEncryptedMessages = Array.isArray(encryptedChatMessages) ? encryptedChatMessages : [];
allTelemetryEntries = Array.isArray(telemetryEntries) ? telemetryEntries : [];
allPositionEntries = Array.isArray(positions) ? positions : [];
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
allTelemetryEntries = aggregatedTelemetry;
allPositionEntries = aggregatedPositions;
allNeighbors = aggregatedNeighbors;
applyFilter();
if (statusEl) {
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();
@@ -118,7 +118,7 @@ export function overlayToPopupNode(source) {
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),
channel_utilization: toFiniteNumber(source.channel_utilization ?? source.channelUtilization),
air_util_tx: toFiniteNumber(source.airUtil ?? source.air_util_tx),
temperature: toFiniteNumber(source.temperature),
relative_humidity: toFiniteNumber(source.humidity ?? source.relative_humidity),
+61 -5
View File
@@ -279,6 +279,63 @@ function normaliseEmojiValue(value) {
return str.length > 0 ? str : null;
}
/**
* Identify whether ``message`` represents a reaction payload.
*
* @param {?Object} message Message payload.
* @returns {boolean} True when the payload is a reaction.
*/
function isReactionMessage(message) {
if (!message || typeof message !== 'object') {
return false;
}
const portnum = toTrimmedString(message.portnum ?? message.portNum);
if (portnum && portnum.toUpperCase() === 'REACTION_APP') {
return true;
}
const hasEmoji = !!normaliseEmojiValue(message.emoji);
if (!hasEmoji) {
return false;
}
return message.reply_id != null || message.replyId != null || !!portnum;
}
/**
* Derive the message text segment, suppressing reaction placeholders.
*
* @param {?Object} message Message payload.
* @param {boolean} isReaction Whether the payload is a reaction.
* @returns {?string} Text segment to render.
*/
function resolveMessageTextSegment(message, isReaction) {
if (!message || typeof message !== 'object') {
return null;
}
if (message.text == null) {
return null;
}
const textString = String(message.text);
if (textString.length === 0) {
return null;
}
if (!isReaction) {
return textString;
}
const trimmed = textString.trim();
if (trimmed.length === 0) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
if (parsed <= 1) {
return null;
}
return `×${parsed}`;
}
return trimmed;
}
/**
* Build the rendered message body containing text and optional emoji.
*
@@ -301,11 +358,10 @@ export function buildMessageBody({ message, escapeHtml, renderEmojiHtml }) {
}
const segments = [];
if (message.text != null) {
const textString = String(message.text);
if (textString.length > 0) {
segments.push(escapeHtml(textString));
}
const reaction = isReactionMessage(message);
const textSegment = resolveMessageTextSegment(message, reaction);
if (textSegment) {
segments.push(escapeHtml(textSegment));
}
const emoji = normaliseEmojiValue(message.emoji);
if (emoji) {
@@ -0,0 +1,234 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { fetchNodeDetailHtml } from './node-page.js';
/**
* Escape a string for safe HTML injection.
*
* @param {*} value Raw input value.
* @returns {string} Escaped string.
*/
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Normalise a candidate label by trimming whitespace.
*
* @param {*} value Raw label value.
* @returns {string} Trimmed label or ``''``.
*/
function normaliseLabel(value) {
if (typeof value !== 'string') return '';
const trimmed = value.trim();
return trimmed.length ? trimmed : '';
}
/**
* Determine whether the supplied reference contains either a node identifier or number.
*
* @param {*} reference Candidate node reference.
* @returns {boolean} ``true`` when the reference is usable.
*/
function hasValidReference(reference) {
if (!reference || typeof reference !== 'object') {
return false;
}
const explicitId = reference.nodeId ?? reference.node_id;
const explicitNum = reference.nodeNum ?? reference.node_num ?? reference.num;
return (explicitId != null && String(explicitId).trim().length > 0) || explicitNum != null;
}
/**
* Create a controller that renders the node detail page inside a modal overlay.
*
* @param {{
* document?: Document,
* overlayId?: string,
* fetchNodeDetail?: Function,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* privateMode?: boolean,
* logger?: Console
* }} [options] Behaviour overrides.
* @returns {{
* open: (reference: Object, config?: { trigger?: Element, label?: string }) => Promise<void>,
* close: () => void,
* isOpen: () => boolean,
* getActiveTrigger: () => ?Element
* }|null} Overlay controller or ``null`` when markup is unavailable.
*/
export function createNodeDetailOverlayManager(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.getElementById !== 'function') {
throw new TypeError('A document with getElementById support is required');
}
const overlayId = options.overlayId ?? 'nodeDetailOverlay';
const overlay = documentRef.getElementById(overlayId);
if (!overlay || typeof overlay.querySelector !== 'function') {
return null;
}
const dialog = overlay.querySelector('.node-detail-overlay__dialog');
const closeButton = overlay.querySelector('.node-detail-overlay__close');
const content = overlay.querySelector('.node-detail-overlay__content');
if (!dialog || !closeButton || !content) {
return null;
}
const fetchDetail = typeof options.fetchNodeDetail === 'function' ? options.fetchNodeDetail : fetchNodeDetailHtml;
const logger = options.logger ?? console;
const privateMode = options.privateMode === true;
const fetchImpl = options.fetchImpl;
const refreshImpl = options.refreshImpl;
const renderShortHtml = options.renderShortHtml;
let requestToken = 0;
let lastTrigger = null;
let isVisible = false;
let keydownHandler = null;
function lockBodyScroll(lock) {
if (!documentRef.body || !documentRef.body.style) {
return;
}
if (lock) {
documentRef.body.style.overflow = 'hidden';
} else {
documentRef.body.style.removeProperty('overflow');
}
}
function setStatus(message, { isError = false } = {}) {
const safe = escapeHtml(message || 'Loading node details…');
const errorClass = isError ? ' node-detail-overlay__status--error' : '';
content.innerHTML = `<p class="node-detail-overlay__status${errorClass}">${safe}</p>`;
}
function attachKeydown() {
if (keydownHandler || typeof documentRef.addEventListener !== 'function') {
return;
}
keydownHandler = event => {
if (event && event.key === 'Escape') {
if (typeof event.preventDefault === 'function') {
event.preventDefault();
}
close();
}
};
documentRef.addEventListener('keydown', keydownHandler);
}
function detachKeydown() {
if (!keydownHandler || typeof documentRef.removeEventListener !== 'function') {
return;
}
documentRef.removeEventListener('keydown', keydownHandler);
keydownHandler = null;
}
function close() {
if (!isVisible) return;
isVisible = false;
overlay.hidden = true;
lockBodyScroll(false);
detachKeydown();
requestToken += 1;
const trigger = lastTrigger;
lastTrigger = null;
if (trigger && typeof trigger.focus === 'function') {
trigger.focus();
}
}
if (typeof closeButton.addEventListener === 'function') {
closeButton.addEventListener('click', event => {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
close();
});
}
if (typeof overlay.addEventListener === 'function') {
overlay.addEventListener('click', event => {
if (event && event.target === overlay) {
close();
}
});
}
async function open(reference, { trigger, label } = {}) {
if (!hasValidReference(reference)) {
throw new TypeError('A node identifier is required to open the detail overlay');
}
lastTrigger = trigger ?? null;
const loadingLabel = normaliseLabel(label);
overlay.hidden = false;
isVisible = true;
lockBodyScroll(true);
attachKeydown();
if (typeof dialog.focus === 'function') {
dialog.focus();
}
if (loadingLabel) {
setStatus(`Loading ${loadingLabel}`);
} else {
setStatus('Loading node details…');
}
const currentToken = ++requestToken;
try {
const html = await fetchDetail(reference, {
fetchImpl,
refreshImpl,
renderShortHtml,
privateMode,
});
if (currentToken !== requestToken) {
return;
}
content.innerHTML = html;
if (typeof closeButton.focus === 'function') {
closeButton.focus();
}
} catch (error) {
if (logger && typeof logger.error === 'function') {
logger.error('Failed to render node detail overlay', error);
}
if (currentToken !== requestToken) {
return;
}
setStatus('Failed to load node details.', { isError: true });
if (typeof closeButton.focus === 'function') {
closeButton.focus();
}
}
}
return {
open,
close,
isOpen: () => isVisible && !overlay.hidden,
getActiveTrigger: () => lastTrigger,
};
}
+41 -11
View File
@@ -15,10 +15,18 @@
*/
import { extractModemMetadata } from './node-modem-metadata.js';
import { normalizeNodeSnapshot } from './node-snapshot-normalizer.js';
import {
SNAPSHOT_WINDOW,
aggregateNeighborSnapshots,
aggregateNodeSnapshots,
aggregatePositionSnapshots,
aggregateTelemetrySnapshots,
} from './snapshot-aggregator.js';
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
const TELEMETRY_LIMIT = 1;
const POSITION_LIMIT = 1;
const TELEMETRY_LIMIT = 1000;
const POSITION_LIMIT = SNAPSHOT_WINDOW;
const NEIGHBOR_LIMIT = 1000;
/**
@@ -176,7 +184,7 @@ function mergeNodeFields(target, record) {
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, 'channel', extractNumber(record, ['channel_utilization', 'channelUtilization', 'channel']));
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']));
@@ -207,7 +215,7 @@ function mergeTelemetry(target, telemetry) {
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, 'channel', extractNumber(telemetry, ['channel_utilization', 'channelUtilization', 'channel']), { 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 });
@@ -351,7 +359,7 @@ export async function refreshNodeInformation(reference, options = {}) {
const [nodeRecord, telemetryRecords, positionRecords, neighborRecords] = await Promise.all([
(async () => {
const response = await fetchImpl(`/api/nodes/${encodedId}`, DEFAULT_FETCH_OPTIONS);
const response = await fetchImpl(`/api/nodes/${encodedId}?limit=${SNAPSHOT_WINDOW}`, DEFAULT_FETCH_OPTIONS);
if (response.status === 404) return null;
if (!response.ok) {
throw new Error(`Failed to load node information (HTTP ${response.status})`);
@@ -384,17 +392,36 @@ export async function refreshNodeInformation(reference, options = {}) {
})(),
]);
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 nodeCandidates = Array.isArray(nodeRecord)
? nodeRecord.filter(isObject)
: (isObject(nodeRecord) ? [nodeRecord] : []);
const aggregatedNodeRecords = aggregateNodeSnapshots(nodeCandidates);
const nodeRecordEntry = aggregatedNodeRecords[0] ?? null;
const telemetryCandidates = Array.isArray(telemetryRecords)
? telemetryRecords.filter(isObject)
: (isObject(telemetryRecords) ? [telemetryRecords] : []);
const aggregatedTelemetry = aggregateTelemetrySnapshots(telemetryCandidates);
const telemetryEntry = aggregatedTelemetry[0] ?? null;
const positionCandidates = Array.isArray(positionRecords)
? positionRecords
: (isObject(positionRecords) ? [positionRecords] : []);
const aggregatedPositions = aggregatePositionSnapshots(positionCandidates);
const positionEntry = aggregatedPositions[0] ?? null;
const neighborCandidates = Array.isArray(neighborRecords)
? neighborRecords
: (isObject(neighborRecords) ? [neighborRecords] : []);
const neighborEntries = aggregateNeighborSnapshots(neighborCandidates);
const node = { neighbors: neighborEntries };
if (normalized.fallback) {
mergeNodeFields(node, normalized.fallback);
}
if (nodeRecord) {
mergeNodeFields(node, nodeRecord);
if (nodeRecordEntry) {
mergeNodeFields(node, nodeRecordEntry);
}
if (normalized.nodeId && !node.nodeId) {
node.nodeId = normalized.nodeId;
@@ -420,12 +447,15 @@ export async function refreshNodeInformation(reference, options = {}) {
}
node.rawSources = {
node: nodeRecord,
node: nodeRecordEntry,
telemetry: telemetryEntry,
telemetrySnapshots: telemetryCandidates,
position: positionEntry,
neighbors: neighborEntries,
};
normalizeNodeSnapshot(node);
return node;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determine whether the supplied value acts like an object instance.
*
* @param {*} value Candidate reference.
* @returns {boolean} True when the value is non-null and of type ``object``.
*/
function isObject(value) {
return value != null && typeof value === 'object';
}
/**
* Convert a raw value into a trimmed string when possible.
*
* @param {*} value Candidate value.
* @returns {string|null} Trimmed string or ``null`` when blank.
*/
function normalizeString(value) {
if (value == null) return null;
const str = String(value).trim();
return str.length === 0 ? null : str;
}
/**
* Convert a raw value into a finite number when possible.
*
* @param {*} value Candidate numeric value.
* @returns {number|null} Finite number or ``null`` when coercion fails.
*/
function normalizeNumber(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (value == null || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Field alias metadata describing how canonical keys map to alternate names.
*
* @type {Array<{keys: Array<string>, normalise?: (value: *) => *}>}
*/
const FIELD_ALIASES = Object.freeze([
{ keys: ['node_id', 'nodeId'], normalise: normalizeString },
{ keys: ['node_num', 'nodeNum', 'num'], normalise: normalizeNumber },
{ keys: ['short_name', 'shortName'], normalise: normalizeString },
{ keys: ['long_name', 'longName'], normalise: normalizeString },
{ keys: ['role'], normalise: normalizeString },
{ keys: ['hw_model', 'hwModel'], normalise: normalizeString },
{ keys: ['modem_preset', 'modemPreset'], normalise: normalizeString },
{ keys: ['lora_freq', 'loraFreq'], normalise: normalizeNumber },
{ keys: ['battery_level', 'battery', 'batteryLevel'], normalise: normalizeNumber },
{ keys: ['voltage'], normalise: normalizeNumber },
{ keys: ['uptime_seconds', 'uptime', 'uptimeSeconds'], normalise: normalizeNumber },
{ keys: ['channel_utilization', 'channelUtilization', 'channel'], normalise: normalizeNumber },
{ keys: ['air_util_tx', 'airUtilTx', 'airUtil'], normalise: normalizeNumber },
{ keys: ['temperature'], normalise: normalizeNumber },
{ keys: ['relative_humidity', 'relativeHumidity', 'humidity'], normalise: normalizeNumber },
{ keys: ['barometric_pressure', 'barometricPressure', 'pressure'], normalise: normalizeNumber },
{ keys: ['gas_resistance', 'gasResistance'], normalise: normalizeNumber },
{ keys: ['snr'], normalise: normalizeNumber },
{ keys: ['last_heard', 'lastHeard'], normalise: normalizeNumber },
{ keys: ['last_seen_iso', 'lastSeenIso'], normalise: normalizeString },
{ keys: ['telemetry_time', 'telemetryTime'], normalise: normalizeNumber },
{ keys: ['position_time', 'positionTime'], normalise: normalizeNumber },
{ keys: ['position_time_iso', 'positionTimeIso'], normalise: normalizeString },
{ keys: ['latitude', 'lat'], normalise: normalizeNumber },
{ keys: ['longitude', 'lon'], normalise: normalizeNumber },
{ keys: ['altitude', 'alt'], normalise: normalizeNumber },
{ keys: ['distance_km', 'distanceKm'], normalise: normalizeNumber },
{ keys: ['precision_bits', 'precisionBits'], normalise: normalizeNumber },
]);
/**
* Resolve the first usable value amongst the provided alias keys.
*
* @param {Object} node Node snapshot inspected for values.
* @param {{keys: Array<string>, normalise?: Function}} config Alias metadata.
* @returns {*|null} Normalized value or ``null``.
*/
function resolveAliasValue(node, config) {
if (!isObject(node)) return null;
for (const key of config.keys) {
if (!Object.prototype.hasOwnProperty.call(node, key)) continue;
const raw = node[key];
const value = typeof config.normalise === 'function'
? config.normalise(raw)
: raw;
if (value != null) {
return value;
}
}
return null;
}
/**
* Populate alias keys with the supplied value.
*
* @param {Object} node Node snapshot mutated in-place.
* @param {{keys: Array<string>}} config Alias metadata.
* @param {*} value Canonical value assigned to all aliases.
* @returns {void}
*/
function assignAliasValue(node, config, value) {
for (const key of config.keys) {
node[key] = value;
}
}
/**
* Normalise a node snapshot to ensure canonical telemetry and identity fields
* exist under all supported aliases.
*
* @param {*} node Candidate node snapshot.
* @returns {*} Normalised node snapshot.
*/
export function normalizeNodeSnapshot(node) {
if (!isObject(node)) {
return node;
}
for (const aliasConfig of FIELD_ALIASES) {
const value = resolveAliasValue(node, aliasConfig);
if (value == null) continue;
assignAliasValue(node, aliasConfig, value);
}
return node;
}
/**
* Apply {@link normalizeNodeSnapshot} to each node in the provided collection.
*
* @param {Array<*>} nodes Node collection.
* @returns {Array<*>} Normalised node collection.
*/
export function normalizeNodeCollection(nodes) {
if (!Array.isArray(nodes)) {
return nodes;
}
nodes.forEach(node => {
normalizeNodeSnapshot(node);
});
return nodes;
}
export const __testUtils = {
isObject,
normalizeString,
normalizeNumber,
FIELD_ALIASES,
resolveAliasValue,
assignAliasValue,
};
@@ -0,0 +1,105 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Convert raw values to finite numeric coordinates when possible.
*
* @param {*} value Raw coordinate value.
* @returns {number|null} Parsed coordinate or ``null`` when invalid.
*/
function toFiniteCoordinate(value) {
if (value == null || value === '') return null;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num) ? num : null;
}
/**
* Enhance a table cell so that it contains a clickable link capable of
* focusing the map on the provided coordinates.
*
* @param {{
* cell: { replaceChildren?: Function } | null,
* document: { createElement: Function } | Document,
* displayText: string,
* formattedLatitude?: string,
* formattedLongitude?: string,
* lat: *,
* lon: *,
* nodeName?: string,
* onActivate?: (lat: number, lon: number) => boolean | void,
* linkClassName?: string
* }} options Enhancement configuration.
* @returns {HTMLElement|null} The created link when enhancement succeeds.
*/
export function enhanceCoordinateCell({
cell,
document,
displayText,
formattedLatitude,
formattedLongitude,
lat,
lon,
nodeName,
onActivate,
linkClassName = 'nodes-coordinate-link'
}) {
if (!cell || typeof cell.replaceChildren !== 'function') return null;
if (!displayText) return null;
const latNum = toFiniteCoordinate(lat);
const lonNum = toFiniteCoordinate(lon);
if (latNum == null || lonNum == null) return null;
const doc = document && typeof document.createElement === 'function' ? document : null;
if (!doc) return null;
const link = doc.createElement('a');
link.className = linkClassName;
link.textContent = displayText;
if (typeof link.setAttribute === 'function') {
link.setAttribute('href', '#');
} else {
link.href = '#';
}
if (!link.dataset) link.dataset = {};
link.dataset.lat = String(latNum);
link.dataset.lon = String(lonNum);
const coordsSummary = [formattedLatitude, formattedLongitude].filter(Boolean).join(', ');
const displayName = nodeName ? String(nodeName) : 'node';
const ariaLabelBase = `Center map on ${displayName}`;
const ariaLabel = coordsSummary ? `${ariaLabelBase} at ${coordsSummary}` : ariaLabelBase;
if (typeof link.setAttribute === 'function') {
link.setAttribute('aria-label', ariaLabel);
}
link.addEventListener('click', event => {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
if (typeof onActivate === 'function') {
onActivate(latNum, lonNum);
}
});
cell.replaceChildren(link);
return link;
}
export const __testUtils = {
toFiniteCoordinate
};
+119
View File
@@ -0,0 +1,119 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Default zoom level used when focusing the map on a specific node.
*
* @type {number}
*/
export const DEFAULT_NODE_FOCUS_ZOOM = 15;
/**
* Convert arbitrary values to finite coordinates when possible.
*
* @param {*} value Raw coordinate value.
* @returns {number|null} Parsed coordinate or ``null`` when invalid.
*/
function toFiniteCoordinate(value) {
if (value == null || value === '') return null;
const num = typeof value === 'number' ? value : Number(value);
return Number.isFinite(num) ? num : null;
}
/**
* Build a handler that recentres a map instance on a set of coordinates.
*
* @param {{
* getMap: () => ({
* setView?: Function,
* flyTo?: Function,
* panTo?: Function,
* setZoom?: Function
* }) | null,
* autoFitController?: { handleUserInteraction?: Function } | null,
* leaflet?: { latLng?: Function } | null,
* defaultZoom?: number,
* setMapCenter?: (value: unknown) => void
* }} dependencies External services used to reposition the map.
* @returns {(lat: *, lon: *, options?: { zoom?: number, animate?: boolean }) => boolean}
* Map focusing function returning ``true`` when the view changed.
*/
export function createMapFocusHandler({
getMap,
autoFitController = null,
leaflet = null,
defaultZoom = DEFAULT_NODE_FOCUS_ZOOM,
setMapCenter = () => {}
}) {
if (typeof getMap !== 'function') {
throw new TypeError('getMap must be a function that returns the active map instance.');
}
const autoFit = autoFitController && typeof autoFitController.handleUserInteraction === 'function'
? autoFitController
: null;
const leafletApi = leaflet && typeof leaflet.latLng === 'function' ? leaflet : null;
const zoomDefault = Number.isFinite(defaultZoom) && defaultZoom > 0 ? defaultZoom : DEFAULT_NODE_FOCUS_ZOOM;
const updateCenter = typeof setMapCenter === 'function' ? setMapCenter : () => {};
return (lat, lon, options = {}) => {
const map = getMap();
if (!map) return false;
const latNum = toFiniteCoordinate(lat);
const lonNum = toFiniteCoordinate(lon);
if (latNum == null || lonNum == null) return false;
const zoomCandidate = toFiniteCoordinate(options.zoom);
const zoom = zoomCandidate != null ? zoomCandidate : zoomDefault;
if (!Number.isFinite(zoom) || zoom <= 0) return false;
if (autoFit) {
autoFit.handleUserInteraction();
}
const target = [latNum, lonNum];
const animate = options.animate !== false;
if (typeof map.setView === 'function') {
map.setView(target, zoom, { animate });
} else if (typeof map.flyTo === 'function') {
map.flyTo(target, zoom, { animate });
} else if (typeof map.panTo === 'function') {
map.panTo(target, { animate });
if (typeof map.setZoom === 'function') {
map.setZoom(zoom);
}
} else {
return false;
}
if (leafletApi) {
try {
const latLng = leafletApi.latLng(latNum, lonNum);
updateCenter(latLng);
return true;
} catch (error) {
// Fall through to the numeric fallback below when Leaflet rejects the coordinates.
}
}
updateCenter({ lat: latNum, lon: lonNum });
return true;
};
}
export const __testUtils = {
toFiniteCoordinate
};
+9
View File
@@ -26,6 +26,7 @@
* contactLink: string,
* contactLinkUrl: string | null,
* mapCenter: { lat: number, lon: number },
* mapZoom: number | null,
* maxDistanceKm: number,
* tileFilters: { light: string, dark: string }
* }}
@@ -39,6 +40,7 @@ export const DEFAULT_CONFIG = {
contactLink: '#potatomesh:dod.ngo',
contactLinkUrl: 'https://matrix.to/#/#potatomesh:dod.ngo',
mapCenter: { lat: 38.761944, lon: -27.090833 },
mapZoom: null,
maxDistanceKm: 42,
tileFilters: {
light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)',
@@ -79,5 +81,12 @@ export function mergeConfig(raw) {
config.maxDistanceKm = Number.isFinite(maxDistance)
? maxDistance
: DEFAULT_CONFIG.maxDistanceKm;
const mapZoomValue = raw?.mapZoom;
if (mapZoomValue == null || mapZoomValue === '') {
config.mapZoom = null;
} else {
const zoom = Number(mapZoomValue);
config.mapZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : null;
}
return config;
}
@@ -280,7 +280,7 @@ export const TELEMETRY_FIELDS = [
{
key: 'channel',
label: 'Channel Util',
sources: ['channel_utilization', 'channelUtilization', 'channel'],
sources: ['channel_utilization', 'channelUtilization'],
formatter: value => fmtTx(value),
},
{
@@ -0,0 +1,267 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Number of snapshots to merge for each entity when aggregating records.
*
* @type {number}
*/
export const SNAPSHOT_WINDOW = 7;
/**
* Determine whether a candidate behaves like an object.
*
* @param {*} value Candidate value to inspect.
* @returns {boolean} ``true`` when the value is a non-null object.
*/
function isObject(value) {
return value != null && typeof value === 'object';
}
/**
* Convert a raw identifier into a trimmed canonical string.
*
* @param {*} value Raw identifier.
* @returns {string|null} Normalised identifier or ``null`` when blank.
*/
function normaliseId(value) {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
/**
* Convert a raw numeric identifier into a finite number.
*
* @param {*} value Raw numeric identifier.
* @returns {number|null} Finite number or ``null`` when coercion fails.
*/
function normaliseNum(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (value == null || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Merge snapshot fields into the destination object, skipping ``null`` values.
*
* @param {Object} target Destination object mutated in-place.
* @param {Object} snapshot Snapshot payload merged into ``target``.
* @returns {void}
*/
function mergeSnapshotFields(target, snapshot) {
if (!isObject(target) || !isObject(snapshot)) return;
for (const key of Object.keys(snapshot)) {
const value = snapshot[key];
if (value == null) continue;
if (typeof value === 'number' && Number.isNaN(value)) continue;
target[key] = value;
}
}
/**
* Build a key resolver that keeps node identifiers and numeric references
* associated with a single aggregate key.
*
* @returns {(entry: Object) => string|null} Key resolver function.
*/
function createNodeKeyResolver() {
const byId = new Map();
const byNum = new Map();
return entry => {
if (!isObject(entry)) return null;
const nodeId = normaliseId(entry.node_id ?? entry.nodeId);
const nodeNum = normaliseNum(entry.node_num ?? entry.nodeNum ?? entry.num);
if (nodeId && byId.has(nodeId)) {
const key = byId.get(nodeId);
if (nodeNum != null && !byNum.has(nodeNum)) {
byNum.set(nodeNum, key);
}
return key;
}
if (nodeNum != null && byNum.has(nodeNum)) {
const key = byNum.get(nodeNum);
if (nodeId) byId.set(nodeId, key);
return key;
}
let key = null;
if (nodeId) {
key = `id:${nodeId}`;
} else if (nodeNum != null) {
key = `num:${nodeNum}`;
}
if (key) {
if (nodeId) byId.set(nodeId, key);
if (nodeNum != null) byNum.set(nodeNum, key);
}
return key;
};
}
/**
* Ensure a property is attached to the aggregate object without exposing it
* through enumeration.
*
* @param {Object} target Destination aggregate object.
* @param {string} key Property name to assign.
* @param {*} value Property value.
* @returns {void}
*/
function defineHiddenProperty(target, key, value) {
Object.defineProperty(target, key, {
value,
enumerable: false,
configurable: false,
writable: false,
});
}
/**
* Aggregate a collection of snapshots by key, merging up to
* {@link SNAPSHOT_WINDOW} entries for each logical entity.
*
* The supplied ``keySelector`` determines which entries belong to the same
* aggregate. Snapshots are merged in chronological order (oldest to newest),
* allowing recent values to override stale ones while retaining older data for
* fields that may be absent in the latest packet.
*
* @template T
* @param {Array<Object>} entries Raw snapshot entries.
* @param {{
* keySelector: (entry: Object) => string|null,
* limit?: number,
* merge?: (target: Object, snapshot: Object) => void,
* baseFactory?: (snapshot: Object) => T
* }} options Aggregation behaviour overrides.
* @returns {Array<T>} Aggregated snapshots.
*/
export function aggregateSnapshots(entries, {
keySelector,
limit = SNAPSHOT_WINDOW,
merge = mergeSnapshotFields,
baseFactory = () => ({}),
} = {}) {
if (typeof keySelector !== 'function') {
throw new TypeError('aggregateSnapshots requires a keySelector function');
}
if (!Array.isArray(entries) || entries.length === 0) {
return [];
}
const groups = new Map();
const maxSnapshots = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : SNAPSHOT_WINDOW;
for (const entry of entries) {
if (!isObject(entry)) continue;
const key = keySelector(entry);
if (!key) continue;
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
if (group.length >= maxSnapshots) continue;
group.push(entry);
}
const aggregates = [];
for (const group of groups.values()) {
if (!Array.isArray(group) || group.length === 0) continue;
const baseSnapshot = group[group.length - 1];
const target = baseFactory(isObject(baseSnapshot) ? { ...baseSnapshot } : {});
const orderedSnapshots = [];
for (let idx = group.length - 1; idx >= 0; idx -= 1) {
const snapshot = group[idx];
if (!isObject(snapshot)) continue;
const clone = { ...snapshot };
orderedSnapshots.push(clone);
merge(target, clone);
}
defineHiddenProperty(target, 'snapshots', orderedSnapshots);
defineHiddenProperty(target, 'latestSnapshot', orderedSnapshots[orderedSnapshots.length - 1] ?? null);
aggregates.push(target);
}
return aggregates;
}
/**
* Aggregate node records into enriched snapshots keyed by identifier.
*
* @param {Array<Object>} entries Node records fetched from the API.
* @param {{ limit?: number }} [options] Aggregation options.
* @returns {Array<Object>} Aggregated node payloads.
*/
export function aggregateNodeSnapshots(entries, { limit = SNAPSHOT_WINDOW } = {}) {
const resolveKey = createNodeKeyResolver();
return aggregateSnapshots(entries, { keySelector: resolveKey, limit });
}
/**
* Aggregate telemetry packets for each node.
*
* @param {Array<Object>} entries Telemetry payloads.
* @param {{ limit?: number }} [options] Aggregation options.
* @returns {Array<Object>} Aggregated telemetry data.
*/
export function aggregateTelemetrySnapshots(entries, { limit = SNAPSHOT_WINDOW } = {}) {
const resolveKey = createNodeKeyResolver();
return aggregateSnapshots(entries, { keySelector: resolveKey, limit });
}
/**
* Aggregate position packets for each node.
*
* @param {Array<Object>} entries Position payloads.
* @param {{ limit?: number }} [options] Aggregation options.
* @returns {Array<Object>} Aggregated position data.
*/
export function aggregatePositionSnapshots(entries, { limit = SNAPSHOT_WINDOW } = {}) {
const resolveKey = createNodeKeyResolver();
return aggregateSnapshots(entries, { keySelector: resolveKey, limit });
}
/**
* Aggregate neighbour packets for each node pair.
*
* @param {Array<Object>} entries Neighbour payloads.
* @param {{ limit?: number }} [options] Aggregation options.
* @returns {Array<Object>} Aggregated neighbour data.
*/
export function aggregateNeighborSnapshots(entries, { limit = SNAPSHOT_WINDOW } = {}) {
const resolveSourceKey = createNodeKeyResolver();
const resolveNeighborKey = createNodeKeyResolver();
return aggregateSnapshots(entries, {
limit,
keySelector: entry => {
if (!isObject(entry)) return null;
const sourceKey = resolveSourceKey(entry);
const neighborId = entry.neighbor_id ?? entry.neighborId;
const neighborNum = entry.neighbor_num ?? entry.neighborNum;
const neighborKey = resolveNeighborKey({ node_id: neighborId, node_num: neighborNum });
if (!sourceKey || !neighborKey) return null;
return `${sourceKey}->${neighborKey}`;
},
});
}
export const __testUtils = {
isObject,
normaliseId,
normaliseNum,
mergeSnapshotFields,
createNodeKeyResolver,
defineHiddenProperty,
};
+27
View File
@@ -51,6 +51,12 @@
return '; ' + key + '=' + optionValue;
}
/**
* Serialise cookie attributes into a string consumable by ``document.cookie``.
*
* @param {Object<string, *>} options Map of cookie attribute keys and values.
* @returns {string} Concatenated cookie attribute segment.
*/
function serializeCookieOptions(options) {
var buffer = '';
var source = options == null ? {} : options;
@@ -90,6 +96,12 @@
setCookie('theme', value, { 'max-age': THEME_COOKIE_MAX_AGE });
}
/**
* Apply the requested theme to the root HTML and body elements.
*
* @param {string} value Theme identifier, defaults to ``light`` unless ``dark`` is provided.
* @returns {boolean} ``true`` when the dark theme is active.
*/
function applyTheme(value) {
var themeValue = value === 'dark' ? 'dark' : 'light';
var root = document.documentElement;
@@ -107,6 +119,11 @@
return isDark;
}
/**
* Trigger the setCookie helper through a monkey-patched ``hasOwnProperty`` to keep coverage deterministic.
*
* @returns {void}
*/
function exerciseSetCookieGuard() {
var originalHasOwnProperty = Object.prototype.hasOwnProperty;
Object.prototype.hasOwnProperty = function alwaysFalse() {
@@ -121,6 +138,11 @@
var theme = 'dark';
/**
* Initialise theme state on page load and register the ready handler.
*
* @returns {void}
*/
function bootstrap() {
document.removeEventListener('DOMContentLoaded', handleReady);
theme = getCookie('theme');
@@ -137,6 +159,11 @@
}
}
/**
* Update UI elements once the DOM is ready.
*
* @returns {void}
*/
function handleReady() {
var isDark = applyTheme(theme);
+364 -1
View File
@@ -754,7 +754,7 @@ body.view-map .map-panel--full #map {
line-height: 1.4;
min-width: 200px;
max-width: 240px;
z-index: 12000;
z-index: 20000;
}
.short-info-overlay[hidden] {
@@ -779,6 +779,369 @@ body.view-map .map-panel--full #map {
background: rgba(0, 0, 0, 0.08);
}
.node-detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 15000;
}
.node-detail-overlay[hidden] {
display: none;
}
.node-detail-overlay__dialog {
width: 90vw;
height: 90vh;
max-width: 1400px;
max-height: 960px;
background: var(--bg2);
color: var(--fg);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
padding: 32px 24px 24px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.node-detail-overlay__close {
position: absolute;
top: 12px;
right: 12px;
border: none;
background: rgba(0, 0, 0, 0.05);
color: inherit;
font-size: 18px;
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.node-detail-overlay__close:hover {
background: rgba(0, 0, 0, 0.12);
}
.node-detail-overlay__content {
flex: 1;
overflow-y: auto;
padding-right: 6px;
}
.node-detail-overlay__status {
margin: 0;
font-size: 1rem;
color: var(--muted);
}
.node-detail-overlay__status--error {
color: #c62828;
}
body.dark .node-detail-overlay__dialog {
background: var(--bg2);
color: var(--fg);
}
body.dark .node-detail-overlay__close {
background: rgba(255, 255, 255, 0.08);
}
body.dark .node-detail-overlay__close:hover {
background: rgba(255, 255, 255, 0.18);
}
.charts-page {
padding: 24px var(--pad) 48px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
.charts-page__intro {
padding: 0 4px 12px;
}
.charts-page__intro h2 {
margin: 0 0 6px;
font-size: 1.6rem;
}
.charts-page__intro p {
margin: 0;
color: var(--muted);
}
.charts-page__content {
min-height: 320px;
}
.charts-page__status {
margin: 18px 0;
font-size: 1rem;
color: var(--muted);
}
.charts-page__status--error {
color: #c62828;
}
.node-detail {
width: 100%;
margin: 0;
padding: 24px 0 40px;
}
.node-detail__header {
margin-bottom: 12px;
padding: 0 20px;
}
.node-detail__title {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
font-size: 1.4rem;
}
.node-detail__badge {
font-family: ui-monospace, Menlo, Consolas, monospace;
font-size: 1.2rem;
}
.node-detail__table {
margin: 20px 0 32px;
overflow-x: auto;
}
.node-detail__table table {
width: 100%;
}
.node-detail__charts {
padding: 0 20px;
margin: 12px 0 24px;
}
.node-detail__charts-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
}
.node-detail__chart {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.node-detail__chart-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 1rem;
margin: 0;
}
.node-detail__chart-header h4 {
margin: 0;
font-size: 1.2rem;
}
.node-detail__chart-header span {
color: var(--muted);
font-size: 1rem;
}
.node-detail__chart svg {
width: 100%;
height: auto;
max-height: 420px;
}
.node-detail__chart-axis line {
stroke: var(--line);
stroke-width: 1;
}
.node-detail__chart-axis text,
.node-detail__chart-axis-label {
fill: var(--muted);
font-size: 0.95rem;
}
.node-detail__chart-grid-line {
stroke: rgba(12, 15, 18, 0.08);
stroke-width: 1;
}
body.dark .node-detail__chart-grid-line {
stroke: rgba(255, 255, 255, 0.15);
}
.node-detail__chart-point {
stroke: none;
}
.node-detail__chart-legend {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
}
.node-detail__chart-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 1rem;
}
.node-detail__chart-legend-swatch {
width: 14px;
height: 14px;
border-radius: 999px;
flex: 0 0 auto;
}
.node-detail__chart-legend-text {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.node-detail__chart-legend-text small {
color: var(--muted);
font-size: 0.9rem;
}
.node-detail__identifier {
font-family: ui-monospace, Menlo, Consolas, monospace;
color: var(--muted);
}
.node-long-link {
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
}
.node-long-link:focus,
.node-long-link:hover {
text-decoration-thickness: 2px;
}
.node-detail__status,
.node-detail__error,
.node-detail__noscript {
margin: 16px 0;
padding: 0 20px;
}
.node-detail__error {
color: #b00020;
}
.node-detail__content {
display: grid;
gap: 24px;
padding: 0 20px;
}
.node-detail__neighbors-grid {
display: grid;
gap: 16px;
}
@media (min-width: 720px) {
.node-detail__neighbors-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.node-detail__neighbors-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.node-detail__neighbors-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.node-detail__neighbors-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.node-detail__neighbors-list li {
display: flex;
align-items: center;
gap: 10px;
}
.node-detail__neighbor-snr {
font-family: ui-monospace, Menlo, Consolas, monospace;
color: var(--muted);
font-size: 0.95rem;
}
.node-detail__section h3 {
margin: 0 0 10px;
font-size: 1.1rem;
}
.node-detail__list {
margin: 0;
padding: 0;
list-style: none;
}
.node-detail__row {
display: grid;
grid-template-columns: minmax(140px, 200px) 1fr;
gap: 8px;
margin-bottom: 6px;
}
.node-detail__row dt {
font-weight: 600;
color: var(--muted);
margin: 0;
}
.node-detail__row dd {
margin: 0;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.node-detail__list li {
margin-bottom: 4px;
font-family: ui-monospace, Menlo, Consolas, monospace;
}
.short-info-content {
margin: 0;
}
+39 -1
View File
@@ -1141,7 +1141,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
it "includes the application version in the footer" do
get "/"
expect(last_response.body).to include("#{APP_VERSION}")
expected = APP_VERSION.to_s.start_with?("v") ? APP_VERSION : "v#{APP_VERSION}"
expect(last_response.body).to include(expected)
end
it "renders the responsive footer container" do
@@ -1190,6 +1191,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('<meta property="og:site_name" content="Spec Mesh Title" />')
expect(last_response.body).to include('<meta name="twitter:image" content="http://example.org/potatomesh-logo.svg" />')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(11.0)
get "/"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
end
end
describe "GET /map" do
@@ -1206,6 +1216,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response.body).to include('id="fitBounds"')
expect(last_response.body).not_to include('<footer class="app-footer">')
end
it "disables the auto-fit toggle when a map zoom override is configured" do
allow(PotatoMesh::Config).to receive(:map_zoom).and_return(9.5)
get "/map"
expect(last_response.body).to include('id="fitBounds" disabled="disabled"')
expect(last_response.body).not_to include('id="fitBounds" checked="checked"')
end
end
describe "GET /chat" do
@@ -4222,4 +4241,23 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.first).not_to have_key("portnum")
end
end
describe "GET /nodes/:id" do
before do
import_nodes_fixture
end
it "renders the node detail page with embedded reference data" do
node = nodes_fixture.first
get "/nodes/#{node["node_id"]}"
expect(last_response).to be_ok
expect(last_response.body).to include("data-node-reference=")
expect(last_response.body).to include(node["node_id"])
end
it "returns 404 when the node cannot be located" do
get "/nodes/!deadbeef"
expect(last_response.status).to eq(404)
end
end
end
+24
View File
@@ -426,6 +426,30 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".map_zoom" do
it "returns nil when the override is not provided" do
within_env("MAP_ZOOM" => nil) do
expect(described_class.map_zoom).to be_nil
end
end
it "parses positive numeric overrides" do
within_env("MAP_ZOOM" => "11") do
expect(described_class.map_zoom).to eq(11.0)
end
end
it "rejects non-positive or invalid overrides" do
within_env("MAP_ZOOM" => "0") do
expect(described_class.map_zoom).to be_nil
end
within_env("MAP_ZOOM" => "potato") do
expect(described_class.map_zoom).to be_nil
end
end
end
describe ".max_distance_km" do
it "returns the default distance when unset" do
within_env("MAX_DISTANCE" => nil) do
+28
View File
@@ -0,0 +1,28 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="charts-page">
<header class="charts-page__intro">
<h2>Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every node in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
</div>
</section>
<script type="module">
import { initializeChartsPage } from '/assets/js/app/charts-page.js';
initializeChartsPage();
</script>
+32 -6
View File
@@ -78,8 +78,10 @@
show_meta_info = true
show_auto_refresh_controls = true
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
show_info_button = !full_screen_view
show_footer = !full_screen_view
show_filter_input = !%i[node_detail charts].include?(view_mode)
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
@@ -87,7 +89,12 @@
refresh_row_classes << "refresh-row--no-info" if refresh_info_text.nil?
refresh_info_classes = ["refresh-info"]
refresh_info_classes << "refresh-info--hidden" if refresh_info_text.nil? %>
<body class="<%= body_classes.join(" ") %>" data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>" data-theme="<%= initial_theme %>">
<body
class="<%= body_classes.join(" ") %>"
data-app-config="<%= Rack::Utils.escape_html(app_config_json) %>"
data-theme="<%= initial_theme %>"
data-private-mode="<%= private_mode ? "true" : "false" %>"
>
<div class="<%= shell_classes.join(" ") %>">
<% if show_header %>
<header class="site-header">
@@ -127,12 +134,21 @@
<% end %>
<div class="<%= controls_classes.join(" ") %>">
<% if show_auto_fit_toggle %>
<label><input type="checkbox" id="fitBounds" checked /> Auto-fit map</label>
<% auto_fit_attrs = [] %>
<% if map_zoom_override.nil? %>
<% auto_fit_attrs << 'checked="checked"' %>
<% else %>
<% auto_fit_attrs << 'disabled="disabled"' %>
<% auto_fit_attrs << 'aria-disabled="true"' %>
<% end %>
<label><input type="checkbox" id="fitBounds" <%= auto_fit_attrs.join(" ") %> /> Auto-fit map</label>
<% end %>
<% if show_filter_input %>
<div class="filter-input">
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<% end %>
<div class="filter-input">
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
<% if show_info_button %>
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true">️</span></button>
@@ -165,6 +181,16 @@
</div>
</div>
<div id="nodeDetailOverlay" class="node-detail-overlay" hidden>
<div class="node-detail-overlay__dialog" role="dialog" aria-modal="true" aria-labelledby="nodeDetailOverlayHeader" tabindex="-1">
<h2 id="nodeDetailOverlayHeader" class="visually-hidden">Node details</h2>
<button type="button" class="node-detail-overlay__close" aria-label="Close node details">&times;</button>
<div class="node-detail-overlay__content" aria-live="polite">
<p class="node-detail-overlay__status">Select a node to view details.</p>
</div>
</div>
</div>
<main class="<%= main_classes.join(" ") %>">
<%= yield %>
</main>
+45
View File
@@ -0,0 +1,45 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<% reference_json = node_reference_json || "{}"
short_display = node_page_short_name || "Loading"
long_display = node_page_long_name
identifier_display = node_page_identifier || "" %>
<section
id="nodeDetail"
class="node-detail"
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
data-private-mode="<%= private_mode ? "true" : "false" %>"
>
<header class="node-detail__header">
<h2 class="node-detail__title">
<span class="node-detail__badge" data-node-badge><%= Rack::Utils.escape_html(short_display) %></span>
<% if long_display %>
<span class="node-detail__name" data-node-long-name><%= Rack::Utils.escape_html(long_display) %></span>
<% end %>
<% if identifier_display && !identifier_display.empty? %>
<span class="node-detail__identifier" data-node-identifier>[<%= Rack::Utils.escape_html(identifier_display) %>]</span>
<% end %>
</h2>
</header>
<p class="node-detail__status" data-node-status>Loading node details…</p>
<noscript>
<p class="node-detail__noscript">This page requires JavaScript to display node information.</p>
</noscript>
</section>
<script type="module">
import { initializeNodeDetailPage } from '/assets/js/app/node-page.js';
initializeNodeDetailPage();
</script>