mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-18 07:15:56 +02:00
Compare commits
13 Commits
v0.5.5-rc2
..
v0.5.5
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d43cec57 | |||
| cd7bced827 | |||
| b298f2f22c | |||
| 9304a99745 | |||
| 4a03e17886 | |||
| e502ddd436 | |||
| 12f1801ed2 | |||
| a6a63bf12e | |||
| 631455237f | |||
| 382e2609c9 | |||
| 05efbc5f20 | |||
| 9a45430321 | |||
| cb843d5774 |
@@ -73,3 +73,6 @@ web/.config
|
||||
# JavaScript dependencies
|
||||
node_modules/
|
||||
web/node_modules/
|
||||
|
||||
# Debug symbols
|
||||
ignored.txt
|
||||
|
||||
@@ -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`). |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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(' '), true);
|
||||
assert.equal(messagesHtml.includes(' '), 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([], {}), []);
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>×</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>×</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">×</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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user