mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-07 05:44:50 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 858e9fa189 | |||
| 6fd8e5ad12 | |||
| 09fbc32e48 | |||
| 4591d5acd6 | |||
| 6c711f80b4 | |||
| e61e701240 | |||
| 42f4e80a26 | |||
| 4dc03f33ca | |||
| 5572c6cd12 | |||
| 4f7e66de82 | |||
| c1898037c0 | |||
| efc5f64279 | |||
| 636a203254 | |||
| 2e78fa7a3a |
@@ -0,0 +1,35 @@
|
||||
# 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.
|
||||
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v30
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- name: Run flake checks
|
||||
run: nix flake check
|
||||
@@ -53,6 +53,7 @@ Additional environment variables are optional:
|
||||
| `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`. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor skips when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Controls whether the instance announces itself and crawls peers (`1`) or stays isolated (`0`). |
|
||||
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
|
||||
|
||||
@@ -92,6 +92,7 @@ The web app can be configured with environment variables (defaults shown):
|
||||
| `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. |
|
||||
| `ALLOWED_CHANNELS` | _unset_ | Comma-separated channel names the ingestor accepts; when set, all other channels are skipped before hidden filters. |
|
||||
| `HIDDEN_CHANNELS` | _unset_ | Comma-separated channel names the ingestor will ignore when forwarding packets. |
|
||||
| `FEDERATION` | `1` | Set to `1` to announce your instance and crawl peers, or `0` to disable federation. Private mode overrides this. |
|
||||
| `PRIVATE` | `0` | Set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients from public listings. |
|
||||
@@ -201,11 +202,52 @@ Run the script with `INSTANCE_DOMAIN` and `API_TOKEN` to keep updating
|
||||
node records and parsing new incoming messages. Enable debug output with `DEBUG=1`,
|
||||
specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set it to
|
||||
an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. To keep
|
||||
private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated
|
||||
list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on
|
||||
those channels are discarded instead of being sent to `/api/messages`.
|
||||
interface. `CONNECTION` also accepts Bluetooth device addresses in MAC format (e.g.,
|
||||
`ED:4D:9E:95:CF:60`) or UUID format for macOS (e.g., `C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E`)
|
||||
and the script attempts a BLE connection if available. To keep
|
||||
ingestion limited, set `ALLOWED_CHANNELS` to a comma-separated whitelist (for
|
||||
example `ALLOWED_CHANNELS="Chat,Ops"`); packets on other channels are discarded.
|
||||
Use `HIDDEN_CHANNELS` to block specific channels from the web UI even when they
|
||||
appear in the allowlist.
|
||||
|
||||
## Nix
|
||||
|
||||
For the dev shell, run:
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
```
|
||||
|
||||
The shell provides Ruby plus the Python ingestor dependencies (including `meshtastic`
|
||||
and `protobuf`). To sanity-check that the ingestor starts, run `python -m data.mesh`
|
||||
with the usual environment variables (`INSTANCE_DOMAIN`, `API_TOKEN`, `CONNECTION`).
|
||||
|
||||
To run the packaged apps directly:
|
||||
|
||||
```bash
|
||||
nix run .#web
|
||||
nix run .#ingestor
|
||||
```
|
||||
|
||||
Minimal NixOS module snippet:
|
||||
|
||||
```nix
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiTokenFile = config.sops.secrets.potato-mesh-api-token.path;
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
port = 41447;
|
||||
instanceDomain = "https://mesh.me";
|
||||
siteName = "Nix Mesh";
|
||||
contactLink = "homeserver.mx";
|
||||
mapCenter = "28.96,-13.56";
|
||||
frequency = "868MHz";
|
||||
ingestor = {
|
||||
enable = true;
|
||||
connection = "192.168.X.Y:4403";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.5.8</string>
|
||||
<string>0.5.9</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.5.8</string>
|
||||
<string>0.5.9</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: potato_mesh_reader
|
||||
description: Meshtastic Reader — read-only view for PotatoMesh messages.
|
||||
publish_to: "none"
|
||||
version: 0.5.8
|
||||
version: 0.5.9
|
||||
|
||||
environment:
|
||||
sdk: ">=3.4.0 <4.0.0"
|
||||
|
||||
@@ -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")
|
||||
HIDDEN_CHANNELS=$(grep "^HIDDEN_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
ALLOWED_CHANNELS=$(grep "^ALLOWED_CHANNELS=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
|
||||
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")
|
||||
@@ -127,6 +128,9 @@ echo "-------------------"
|
||||
echo "Private mode hides public mesh messages from unauthenticated visitors."
|
||||
echo "Set to 1 to hide public feeds or 0 to keep them visible."
|
||||
read_with_default "Enable private mode (1=yes, 0=no)" "$PRIVATE" PRIVATE
|
||||
echo "Provide a comma-separated whitelist of channel names to ingest (optional)."
|
||||
echo "When set, only listed channels are ingested unless explicitly hidden below."
|
||||
read_with_default "Allowed channels" "$ALLOWED_CHANNELS" ALLOWED_CHANNELS
|
||||
echo "Provide a comma-separated list of channel names to hide from the web UI (optional)."
|
||||
read_with_default "Hidden channels" "$HIDDEN_CHANNELS" HIDDEN_CHANNELS
|
||||
|
||||
@@ -199,6 +203,11 @@ update_env "POTATOMESH_IMAGE_TAG" "$POTATOMESH_IMAGE_TAG"
|
||||
update_env "FEDERATION" "$FEDERATION"
|
||||
update_env "PRIVATE" "$PRIVATE"
|
||||
update_env "CONNECTION" "$CONNECTION"
|
||||
if [ -n "$ALLOWED_CHANNELS" ]; then
|
||||
update_env "ALLOWED_CHANNELS" "\"$ALLOWED_CHANNELS\""
|
||||
else
|
||||
sed -i.bak '/^ALLOWED_CHANNELS=.*/d' .env
|
||||
fi
|
||||
if [ -n "$HIDDEN_CHANNELS" ]; then
|
||||
update_env "HIDDEN_CHANNELS" "\"$HIDDEN_CHANNELS\""
|
||||
else
|
||||
@@ -252,6 +261,7 @@ echo " API Token: ${API_TOKEN:0:8}..."
|
||||
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
|
||||
echo " Docker Image Tag: $POTATOMESH_IMAGE_TAG"
|
||||
echo " Private Mode: ${PRIVATE}"
|
||||
echo " Allowed Channels: ${ALLOWED_CHANNELS:-'All'}"
|
||||
echo " Hidden Channels: ${HIDDEN_CHANNELS:-'None'}"
|
||||
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
|
||||
if [ "${FEDERATION:-1}" = "0" ]; then
|
||||
|
||||
@@ -50,6 +50,8 @@ USER potatomesh
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
@@ -75,6 +77,8 @@ USER ContainerUser
|
||||
ENV CONNECTION=/dev/ttyACM0 \
|
||||
CHANNEL_INDEX=0 \
|
||||
DEBUG=0 \
|
||||
ALLOWED_CHANNELS="" \
|
||||
HIDDEN_CHANNELS="" \
|
||||
INSTANCE_DOMAIN="" \
|
||||
API_TOKEN=""
|
||||
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
|
||||
message information before forwarding it to the accompanying web application.
|
||||
"""
|
||||
|
||||
VERSION = "0.5.8"
|
||||
VERSION = "0.5.9"
|
||||
"""Semantic version identifier shared with the dashboard and front-end."""
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
@@ -70,6 +70,7 @@ _CONFIG_ATTRS = {
|
||||
"DEBUG",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ALLOWED_CHANNELS",
|
||||
"HIDDEN_CHANNELS",
|
||||
"LORA_FREQ",
|
||||
"MODEM_PRESET",
|
||||
|
||||
@@ -228,6 +228,33 @@ def hidden_channel_names() -> tuple[str, ...]:
|
||||
return tuple(getattr(config, "HIDDEN_CHANNELS", ()))
|
||||
|
||||
|
||||
def allowed_channel_names() -> tuple[str, ...]:
|
||||
"""Return the configured set of explicitly allowed channel names."""
|
||||
|
||||
return tuple(getattr(config, "ALLOWED_CHANNELS", ()))
|
||||
|
||||
|
||||
def is_allowed_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is permitted by policy."""
|
||||
|
||||
allowed = getattr(config, "ALLOWED_CHANNELS", ())
|
||||
if not allowed:
|
||||
return True
|
||||
|
||||
if channel_name_value is None:
|
||||
return False
|
||||
|
||||
normalized = channel_name_value.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
|
||||
normalized_casefold = normalized.casefold()
|
||||
for allowed_name in allowed:
|
||||
if normalized_casefold == allowed_name.casefold():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_hidden_channel(channel_name_value: str | None) -> bool:
|
||||
"""Return ``True`` when ``channel_name_value`` is configured as hidden."""
|
||||
|
||||
@@ -255,7 +282,9 @@ __all__ = [
|
||||
"capture_from_interface",
|
||||
"channel_mappings",
|
||||
"channel_name",
|
||||
"allowed_channel_names",
|
||||
"hidden_channel_names",
|
||||
"is_allowed_channel",
|
||||
"is_hidden_channel",
|
||||
"_reset_channel_cache",
|
||||
]
|
||||
|
||||
@@ -66,8 +66,8 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX)))
|
||||
DEBUG = os.environ.get("DEBUG") == "1"
|
||||
|
||||
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of hidden channel names.
|
||||
def _parse_channel_names(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Normalise a comma-separated list of channel names.
|
||||
|
||||
Parameters:
|
||||
raw_value: Raw environment string containing channel names separated by
|
||||
@@ -96,9 +96,18 @@ def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
return tuple(normalized_entries)
|
||||
|
||||
|
||||
def _parse_hidden_channels(raw_value: str | None) -> tuple[str, ...]:
|
||||
"""Compatibility wrapper that parses hidden channel names."""
|
||||
|
||||
return _parse_channel_names(raw_value)
|
||||
|
||||
|
||||
HIDDEN_CHANNELS = _parse_hidden_channels(os.environ.get("HIDDEN_CHANNELS"))
|
||||
"""Channel names configured to be ignored by the ingestor."""
|
||||
|
||||
ALLOWED_CHANNELS = _parse_channel_names(os.environ.get("ALLOWED_CHANNELS"))
|
||||
"""Explicitly permitted channel names; when set, other channels are ignored."""
|
||||
|
||||
|
||||
def _resolve_instance_domain() -> str:
|
||||
"""Resolve the configured instance domain from the environment.
|
||||
@@ -183,6 +192,7 @@ __all__ = [
|
||||
"CHANNEL_INDEX",
|
||||
"DEBUG",
|
||||
"HIDDEN_CHANNELS",
|
||||
"ALLOWED_CHANNELS",
|
||||
"INSTANCE",
|
||||
"API_TOKEN",
|
||||
"ENERGY_SAVING",
|
||||
|
||||
@@ -100,6 +100,41 @@ from .serialization import (
|
||||
)
|
||||
|
||||
|
||||
def _portnum_candidates(name: str) -> set[int]:
|
||||
"""Return Meshtastic port number candidates for ``name``.
|
||||
|
||||
Parameters:
|
||||
name: Port name to look up in Meshtastic ``PortNum`` enums.
|
||||
|
||||
Returns:
|
||||
Set of integer port numbers resolved from Meshtastic modules.
|
||||
"""
|
||||
|
||||
candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup(name))
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
constant_value = getattr(module, name, None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
candidates.add(candidate)
|
||||
return candidates
|
||||
|
||||
|
||||
def register_host_node_id(node_id: str | None) -> None:
|
||||
"""Record the canonical identifier for the connected host device.
|
||||
|
||||
@@ -1280,28 +1315,7 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
traceroute_section = (
|
||||
decoded.get("traceroute") if isinstance(decoded, Mapping) else None
|
||||
)
|
||||
traceroute_port_ints: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("TRACEROUTE_APP"))
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
constant_value = getattr(module, "TRACEROUTE_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
traceroute_port_ints.add(candidate)
|
||||
traceroute_port_ints = _portnum_candidates("TRACEROUTE_APP")
|
||||
|
||||
if (
|
||||
portnum == "TRACEROUTE_APP"
|
||||
@@ -1359,36 +1373,43 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
if emoji_text:
|
||||
emoji = emoji_text
|
||||
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP"}
|
||||
routing_section = decoded.get("routing") if isinstance(decoded, Mapping) else None
|
||||
routing_port_candidates = _portnum_candidates("ROUTING_APP")
|
||||
if text is None and (
|
||||
portnum == "ROUTING_APP"
|
||||
or (portnum_int is not None and portnum_int in routing_port_candidates)
|
||||
or isinstance(routing_section, Mapping)
|
||||
):
|
||||
routing_payload = _first(decoded, "payload", "data", default=None)
|
||||
if routing_payload is not None:
|
||||
if isinstance(routing_payload, bytes):
|
||||
text = base64.b64encode(routing_payload).decode("ascii")
|
||||
elif isinstance(routing_payload, str):
|
||||
text = routing_payload
|
||||
else:
|
||||
try:
|
||||
text = json.dumps(routing_payload, ensure_ascii=True)
|
||||
except TypeError:
|
||||
text = str(routing_payload)
|
||||
if isinstance(text, str):
|
||||
text = text.strip() or None
|
||||
|
||||
allowed_port_values = {"1", "TEXT_MESSAGE_APP", "REACTION_APP", "ROUTING_APP"}
|
||||
allowed_port_ints = {1}
|
||||
|
||||
reaction_port_candidates: set[int] = set()
|
||||
for module_name in (
|
||||
"meshtastic.portnums_pb2",
|
||||
"meshtastic.protobuf.portnums_pb2",
|
||||
):
|
||||
module = sys.modules.get(module_name)
|
||||
if module is None:
|
||||
with contextlib.suppress(ModuleNotFoundError):
|
||||
module = importlib.import_module(module_name)
|
||||
if module is None:
|
||||
continue
|
||||
portnum_enum = getattr(module, "PortNum", None)
|
||||
value_lookup = getattr(portnum_enum, "Value", None) if portnum_enum else None
|
||||
if callable(value_lookup):
|
||||
with contextlib.suppress(Exception):
|
||||
candidate = _coerce_int(value_lookup("REACTION_APP"))
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
constant_value = getattr(module, "REACTION_APP", None)
|
||||
candidate = _coerce_int(constant_value)
|
||||
if candidate is not None:
|
||||
reaction_port_candidates.add(candidate)
|
||||
|
||||
reaction_port_candidates = _portnum_candidates("REACTION_APP")
|
||||
for candidate in reaction_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
for candidate in routing_port_candidates:
|
||||
allowed_port_ints.add(candidate)
|
||||
allowed_port_values.add(str(candidate))
|
||||
|
||||
if isinstance(routing_section, Mapping) and portnum_int is not None:
|
||||
allowed_port_ints.add(portnum_int)
|
||||
allowed_port_values.add(str(portnum_int))
|
||||
|
||||
is_reaction_packet = portnum == "REACTION_APP" or (
|
||||
reply_id is not None and emoji is not None
|
||||
)
|
||||
@@ -1461,6 +1482,18 @@ def store_packet_dict(packet: Mapping) -> None:
|
||||
_record_ignored_packet(packet, reason="skipped-direct-message")
|
||||
return
|
||||
|
||||
if not channels.is_allowed_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="disallowed-channel")
|
||||
if config.DEBUG:
|
||||
config._debug_log(
|
||||
"Ignored packet on disallowed channel",
|
||||
context="handlers.store_packet_dict",
|
||||
channel=channel,
|
||||
channel_name=channel_name_value,
|
||||
allowed_channels=channels.allowed_channel_names(),
|
||||
)
|
||||
return
|
||||
|
||||
if channels.is_hidden_channel(channel_name_value):
|
||||
_record_ignored_packet(packet, reason="hidden-channel")
|
||||
if config.DEBUG:
|
||||
|
||||
@@ -628,7 +628,13 @@ _DEFAULT_SERIAL_PATTERNS = (
|
||||
"/dev/cu.usbserial*",
|
||||
)
|
||||
|
||||
_BLE_ADDRESS_RE = re.compile(r"^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$")
|
||||
# Support both MAC addresses (Linux/Windows) and UUIDs (macOS)
|
||||
_BLE_ADDRESS_RE = re.compile(
|
||||
r"^(?:"
|
||||
r"(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}|" # MAC address format
|
||||
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" # UUID format
|
||||
r")$"
|
||||
)
|
||||
|
||||
|
||||
class _DummySerialInterface:
|
||||
@@ -642,13 +648,13 @@ class _DummySerialInterface:
|
||||
|
||||
|
||||
def _parse_ble_target(value: str) -> str | None:
|
||||
"""Return an uppercase BLE MAC address when ``value`` matches the format.
|
||||
"""Return a normalized BLE address (MAC or UUID) when ``value`` matches the format.
|
||||
|
||||
Parameters:
|
||||
value: User-provided target string.
|
||||
|
||||
Returns:
|
||||
The normalised MAC address or ``None`` when validation fails.
|
||||
The normalised MAC address or UUID, or ``None`` when validation fails.
|
||||
"""
|
||||
|
||||
if not value:
|
||||
@@ -772,10 +778,13 @@ def _create_serial_interface(port: str) -> tuple[object, str]:
|
||||
return _DummySerialInterface(), "mock"
|
||||
ble_target = _parse_ble_target(port_value)
|
||||
if ble_target:
|
||||
# Determine if it's a MAC address or UUID
|
||||
address_type = "MAC" if ":" in ble_target else "UUID"
|
||||
config._debug_log(
|
||||
"Using BLE interface",
|
||||
context="interfaces.ble",
|
||||
address=ble_target,
|
||||
address_type=address_type,
|
||||
)
|
||||
return _load_ble_interface()(address=ble_target), ble_target
|
||||
network_target = _parse_network_target(port_value)
|
||||
|
||||
@@ -49,6 +49,7 @@ x-ingestor-base: &ingestor-base
|
||||
environment:
|
||||
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
|
||||
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
|
||||
ALLOWED_CHANNELS: ${ALLOWED_CHANNELS:-""}
|
||||
HIDDEN_CHANNELS: ${HIDDEN_CHANNELS:-""}
|
||||
API_TOKEN: ${API_TOKEN}
|
||||
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
|
||||
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766070988,
|
||||
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
{
|
||||
description = "PotatoMesh - A federated, Meshtastic-powered node dashboard";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Python environment for the ingestor
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
meshtastic
|
||||
protobuf
|
||||
requests
|
||||
]);
|
||||
|
||||
# Web app wrapper script
|
||||
webApp = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-web";
|
||||
runtimeInputs = [ pkgs.ruby pkgs.bundler pkgs.sqlite pkgs.git pkgs.gnumake pkgs.gcc ];
|
||||
text = ''
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
WORKDIR="$BASEDIR/web"
|
||||
mkdir -p "$WORKDIR"
|
||||
|
||||
# Copy app files if not present or outdated
|
||||
APP_SRC="${./web}"
|
||||
DATA_SRC="${./data}"
|
||||
if [ ! -f "$WORKDIR/.installed" ] || [ "$APP_SRC" != "$(cat "$WORKDIR/.src_path" 2>/dev/null)" ]; then
|
||||
# Copy web app
|
||||
cp -rT "$APP_SRC" "$WORKDIR/"
|
||||
chmod -R u+w "$WORKDIR"
|
||||
# Copy data directory (contains SQL schemas)
|
||||
mkdir -p "$BASEDIR/data"
|
||||
cp -rT "$DATA_SRC" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
echo "$APP_SRC" > "$WORKDIR/.src_path"
|
||||
rm -f "$WORKDIR/.installed"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
# Install gems if needed
|
||||
if [ ! -f ".installed" ]; then
|
||||
bundle config set --local path 'vendor/bundle'
|
||||
bundle install
|
||||
touch .installed
|
||||
fi
|
||||
|
||||
exec bundle exec ruby app.rb -p "''${PORT:-41447}" -o "''${HOST:-0.0.0.0}"
|
||||
'';
|
||||
};
|
||||
|
||||
# Ingestor wrapper script
|
||||
ingestor = pkgs.writeShellApplication {
|
||||
name = "potato-mesh-ingestor";
|
||||
runtimeInputs = [ pythonEnv ];
|
||||
text = ''
|
||||
# The ingestor needs to run from parent directory with data/ folder
|
||||
if [ -n "''${XDG_DATA_HOME:-}" ]; then
|
||||
BASEDIR="$XDG_DATA_HOME"
|
||||
else
|
||||
BASEDIR="$HOME/.local/share/potato-mesh"
|
||||
fi
|
||||
if [ ! -d "$BASEDIR/data" ]; then
|
||||
mkdir -p "$BASEDIR"
|
||||
cp -rT "${./data}" "$BASEDIR/data/"
|
||||
chmod -R u+w "$BASEDIR/data"
|
||||
fi
|
||||
cd "$BASEDIR"
|
||||
exec python -m data.mesh
|
||||
'';
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
web = webApp;
|
||||
ingestor = ingestor;
|
||||
default = webApp;
|
||||
};
|
||||
|
||||
apps = {
|
||||
web = {
|
||||
type = "app";
|
||||
program = "${webApp}/bin/potato-mesh-web";
|
||||
};
|
||||
ingestor = {
|
||||
type = "app";
|
||||
program = "${ingestor}/bin/potato-mesh-ingestor";
|
||||
};
|
||||
default = self.apps.${system}.web;
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
pkgs.ruby
|
||||
pkgs.bundler
|
||||
pythonEnv
|
||||
pkgs.sqlite
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "PotatoMesh development shell"
|
||||
echo " - Ruby: $(ruby --version)"
|
||||
echo " - Python: $(python --version)"
|
||||
echo ""
|
||||
echo "To run the web app: cd web && bundle install && ./app.sh"
|
||||
echo "To run the ingestor: cd data && python mesh.py"
|
||||
'';
|
||||
};
|
||||
|
||||
checks.potato-mesh-nixos = pkgs.testers.nixosTest {
|
||||
name = "potato-mesh-data-dir";
|
||||
nodes.machine = { lib, ... }: {
|
||||
imports = [ self.nixosModules.default ];
|
||||
services.potato-mesh = {
|
||||
enable = true;
|
||||
apiToken = "test-token";
|
||||
dataDir = "/var/lib/potato-mesh";
|
||||
ingestor.enable = true;
|
||||
};
|
||||
systemd.services.potato-mesh-ingestor.wantedBy = lib.mkForce [];
|
||||
};
|
||||
testScript = ''
|
||||
machine.start
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'XDG_DATA_HOME=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-web.service")
|
||||
machine.succeed("grep -q 'WorkingDirectory=/var/lib/potato-mesh' /etc/systemd/system/potato-mesh-ingestor.service")
|
||||
'';
|
||||
};
|
||||
}
|
||||
) // {
|
||||
# NixOS module
|
||||
nixosModules.default = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.potato-mesh;
|
||||
in {
|
||||
options.services.potato-mesh = {
|
||||
enable = lib.mkEnableOption "PotatoMesh web dashboard";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.web;
|
||||
description = "The potato-mesh web package to use";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 41447;
|
||||
description = "Port to listen on";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Host to bind to";
|
||||
};
|
||||
|
||||
apiToken = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Shared secret that authorizes ingestors and API clients making POST requests. Warning: visible in nix store. Prefer apiTokenFile for production.";
|
||||
};
|
||||
|
||||
apiTokenFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "File containing API_TOKEN=<secret> (recommended for production)";
|
||||
};
|
||||
|
||||
instanceDomain = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Public hostname used for metadata, federation, and generated API links";
|
||||
};
|
||||
|
||||
siteName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "PotatoMesh Demo";
|
||||
description = "Title and header displayed in the UI";
|
||||
};
|
||||
|
||||
channel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#LongFast";
|
||||
description = "Default channel name displayed in the UI";
|
||||
};
|
||||
|
||||
frequency = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "915MHz";
|
||||
description = "Default frequency description displayed in the UI";
|
||||
};
|
||||
|
||||
contactLink = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "#potatomesh:dod.ngo";
|
||||
description = "Chat link or Matrix alias rendered in the footer and overlays";
|
||||
};
|
||||
|
||||
mapCenter = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "38.761944,-27.090833";
|
||||
description = "Latitude and longitude that centre the map on load";
|
||||
};
|
||||
|
||||
mapZoom = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.int;
|
||||
default = null;
|
||||
description = "Fixed Leaflet zoom applied on first load; disables auto-fit when provided";
|
||||
};
|
||||
|
||||
maxDistance = lib.mkOption {
|
||||
type = lib.types.int;
|
||||
default = 42;
|
||||
description = "Maximum distance (km) before node relationships are hidden on the map";
|
||||
};
|
||||
|
||||
debug = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable verbose logging";
|
||||
};
|
||||
|
||||
allowedChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor accepts";
|
||||
};
|
||||
|
||||
hiddenChannels = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Comma-separated channel names the ingestor will ignore";
|
||||
};
|
||||
|
||||
federation = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Announce instance and crawl peers";
|
||||
};
|
||||
|
||||
private = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Hide chat UI, disable message APIs, and exclude hidden clients from public listings";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
default = "/var/lib/potato-mesh";
|
||||
description = "Directory to store database and configuration";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "User to run the service as";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "potato-mesh";
|
||||
description = "Group to run the service as";
|
||||
};
|
||||
|
||||
# Ingestor options
|
||||
ingestor = {
|
||||
enable = lib.mkEnableOption "PotatoMesh Python ingestor";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.ingestor;
|
||||
description = "The potato-mesh ingestor package to use";
|
||||
};
|
||||
|
||||
connection = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/dev/ttyACM0";
|
||||
description = "Connection target: serial port, IP:port for TCP, or Bluetooth address for BLE";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
createHome = true;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = {};
|
||||
|
||||
systemd.services.potato-mesh-web = {
|
||||
description = "PotatoMesh Web Dashboard";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
environment = {
|
||||
RACK_ENV = "production";
|
||||
APP_ENV = "production";
|
||||
PORT = toString cfg.port;
|
||||
HOST = cfg.host;
|
||||
SITE_NAME = cfg.siteName;
|
||||
CHANNEL = cfg.channel;
|
||||
FREQUENCY = cfg.frequency;
|
||||
CONTACT_LINK = cfg.contactLink;
|
||||
MAP_CENTER = cfg.mapCenter;
|
||||
MAX_DISTANCE = toString cfg.maxDistance;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
FEDERATION = if cfg.federation then "1" else "0";
|
||||
PRIVATE = if cfg.private then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
XDG_CONFIG_HOME = "${cfg.dataDir}/config";
|
||||
} // lib.optionalAttrs (cfg.instanceDomain != null) {
|
||||
INSTANCE_DOMAIN = cfg.instanceDomain;
|
||||
} // lib.optionalAttrs (cfg.mapZoom != null) {
|
||||
MAP_ZOOM = toString cfg.mapZoom;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.package}/bin/potato-mesh-web";
|
||||
Restart = "always";
|
||||
RestartSec = 5;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.potato-mesh-ingestor = lib.mkIf cfg.ingestor.enable {
|
||||
description = "PotatoMesh Python Ingestor";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" "potato-mesh-web.service" ];
|
||||
requires = [ "potato-mesh-web.service" ];
|
||||
|
||||
environment = {
|
||||
INSTANCE_DOMAIN = "http://127.0.0.1:${toString cfg.port}";
|
||||
CONNECTION = cfg.ingestor.connection;
|
||||
DEBUG = if cfg.debug then "1" else "0";
|
||||
XDG_DATA_HOME = cfg.dataDir;
|
||||
} // lib.optionalAttrs (cfg.allowedChannels != null) {
|
||||
ALLOWED_CHANNELS = cfg.allowedChannels;
|
||||
} // lib.optionalAttrs (cfg.hiddenChannels != null) {
|
||||
HIDDEN_CHANNELS = cfg.hiddenChannels;
|
||||
} // lib.optionalAttrs (cfg.apiToken != null) {
|
||||
API_TOKEN = cfg.apiToken;
|
||||
};
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
WorkingDirectory = cfg.dataDir;
|
||||
ExecStart = "${cfg.ingestor.package}/bin/potato-mesh-ingestor";
|
||||
Restart = "always";
|
||||
RestartSec = 10;
|
||||
} // lib.optionalAttrs (cfg.apiTokenFile != null) {
|
||||
EnvironmentFile = cfg.apiTokenFile;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
Generated
+1
-1
@@ -814,7 +814,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"mockito",
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[package]
|
||||
name = "potatomesh-matrix-bridge"
|
||||
version = "0.5.8"
|
||||
version = "0.5.9"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -285,6 +285,40 @@ def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
|
||||
mesh_module.INSTANCE = mesh_module.config.INSTANCE
|
||||
|
||||
|
||||
def test_parse_channel_names_applies_allowlist(mesh_module):
|
||||
"""Ensure allowlists reuse the shared channel parser."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
parsed = mesh.config._parse_channel_names(" Primary ,Chat ,primary , Ops ")
|
||||
mesh.ALLOWED_CHANNELS = parsed
|
||||
|
||||
assert parsed == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.allowed_channel_names() == ("Primary", "Chat", "Ops")
|
||||
assert mesh.channels.is_allowed_channel("chat")
|
||||
assert mesh.channels.is_allowed_channel(" ops ")
|
||||
assert not mesh.channels.is_allowed_channel("unknown")
|
||||
assert not mesh.channels.is_allowed_channel(None)
|
||||
assert mesh.config._parse_channel_names("") == ()
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_allowed_channel_defaults_allow_all(mesh_module):
|
||||
"""Ensure unset allowlists do not block any channels."""
|
||||
|
||||
mesh = mesh_module
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
|
||||
try:
|
||||
mesh.ALLOWED_CHANNELS = ()
|
||||
assert mesh.channels.is_allowed_channel("Any")
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
|
||||
|
||||
def test_parse_hidden_channels_deduplicates_names(mesh_module):
|
||||
"""Ensure hidden channel parsing strips blanks and deduplicates."""
|
||||
|
||||
@@ -1895,6 +1929,110 @@ def test_store_packet_dict_allows_primary_channel_broadcast(mesh_module, monkeyp
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_accepts_routing_app_messages(mesh_module, monkeypatch):
|
||||
"""Ensure routing app payloads are treated as message posts."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 333,
|
||||
"rxTime": 999,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {"payload": "GAA=", "portnum": "ROUTING_APP"},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
path, payload, priority = captured[0]
|
||||
assert path == "/api/messages"
|
||||
assert payload["portnum"] == "ROUTING_APP"
|
||||
assert payload["text"] == "GAA="
|
||||
assert payload["channel"] == 0
|
||||
assert payload["encrypted"] is None
|
||||
assert priority == mesh._MESSAGE_POST_PRIORITY
|
||||
|
||||
|
||||
def test_store_packet_dict_serializes_routing_payloads(mesh_module, monkeypatch):
|
||||
"""Ensure routing payloads are serialized when text is absent."""
|
||||
|
||||
mesh = mesh_module
|
||||
captured = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
|
||||
packet = {
|
||||
"id": 334,
|
||||
"rxTime": 1000,
|
||||
"fromId": "!node",
|
||||
"toId": "^all",
|
||||
"channel": 0,
|
||||
"decoded": {
|
||||
"payload": b"\x01\x02",
|
||||
"portnum": "ROUTING_APP",
|
||||
},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AQI="
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["payload"] = {"kind": "ack"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == '{"kind": "ack"}'
|
||||
|
||||
captured.clear()
|
||||
|
||||
packet["decoded"]["portnum"] = 7
|
||||
packet["decoded"]["payload"] = b"\x00"
|
||||
packet["decoded"]["routing"] = {"errorReason": "NONE"}
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured, "Expected numeric routing packet to be stored"
|
||||
_, payload, _ = captured[0]
|
||||
assert payload["text"] == "AA=="
|
||||
|
||||
|
||||
def test_portnum_candidates_reads_enum_values(mesh_module, monkeypatch):
|
||||
"""Ensure portnum candidates include enum and constants when available."""
|
||||
|
||||
mesh = mesh_module
|
||||
module_name = "meshtastic.portnums_pb2"
|
||||
|
||||
class DummyPortNum:
|
||||
@staticmethod
|
||||
def Value(name):
|
||||
if name == "ROUTING_APP":
|
||||
return 7
|
||||
raise KeyError(name)
|
||||
|
||||
dummy_module = types.SimpleNamespace(PortNum=DummyPortNum, ROUTING_APP=8)
|
||||
monkeypatch.setitem(sys.modules, module_name, dummy_module)
|
||||
|
||||
candidates = mesh.handlers._portnum_candidates("ROUTING_APP")
|
||||
|
||||
assert 7 in candidates
|
||||
assert 8 in candidates
|
||||
|
||||
|
||||
def test_store_packet_dict_appends_channel_name(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
@@ -1997,8 +2135,10 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Chat",)
|
||||
mesh.HIDDEN_CHANNELS = ("Chat",)
|
||||
|
||||
try:
|
||||
@@ -2017,6 +2157,77 @@ def test_store_packet_dict_skips_hidden_channel(mesh_module, monkeypatch, capsys
|
||||
assert ignored == ["hidden-channel"]
|
||||
assert "Ignored packet on hidden channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
|
||||
|
||||
def test_store_packet_dict_skips_disallowed_channel(mesh_module, monkeypatch, capsys):
|
||||
mesh = mesh_module
|
||||
mesh.channels._reset_channel_cache()
|
||||
mesh.config.MODEM_PRESET = None
|
||||
|
||||
class DummyInterface:
|
||||
def __init__(self) -> None:
|
||||
self.localNode = SimpleNamespace(
|
||||
channels=[
|
||||
SimpleNamespace(
|
||||
role=1,
|
||||
settings=SimpleNamespace(name="Primary"),
|
||||
),
|
||||
SimpleNamespace(
|
||||
role=2,
|
||||
index=5,
|
||||
settings=SimpleNamespace(name="Chat"),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def waitForConfig(self):
|
||||
return None
|
||||
|
||||
mesh.channels.capture_from_interface(DummyInterface())
|
||||
capsys.readouterr()
|
||||
|
||||
captured: list[tuple[str, dict, int]] = []
|
||||
ignored: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
mesh,
|
||||
"_queue_post_json",
|
||||
lambda path, payload, *, priority: captured.append((path, payload, priority)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mesh.handlers,
|
||||
"_record_ignored_packet",
|
||||
lambda packet, *, reason: ignored.append(reason),
|
||||
)
|
||||
|
||||
previous_debug = mesh.config.DEBUG
|
||||
previous_allowed = mesh.ALLOWED_CHANNELS
|
||||
previous_hidden = mesh.HIDDEN_CHANNELS
|
||||
mesh.config.DEBUG = True
|
||||
mesh.DEBUG = True
|
||||
mesh.ALLOWED_CHANNELS = ("Primary",)
|
||||
mesh.HIDDEN_CHANNELS = ()
|
||||
|
||||
try:
|
||||
packet = {
|
||||
"id": "1001",
|
||||
"rxTime": 25_680,
|
||||
"from": "!sender",
|
||||
"to": "^all",
|
||||
"channel": 5,
|
||||
"decoded": {"text": "disallowed msg", "portnum": 1},
|
||||
}
|
||||
|
||||
mesh.store_packet_dict(packet)
|
||||
|
||||
assert captured == []
|
||||
assert ignored == ["disallowed-channel"]
|
||||
assert "Ignored packet on disallowed channel" in capsys.readouterr().out
|
||||
finally:
|
||||
mesh.ALLOWED_CHANNELS = previous_allowed
|
||||
mesh.HIDDEN_CHANNELS = previous_hidden
|
||||
mesh.config.DEBUG = previous_debug
|
||||
mesh.DEBUG = previous_debug
|
||||
@@ -2533,6 +2744,62 @@ def test_parse_ble_target_rejects_invalid_values(mesh_module):
|
||||
assert mesh._parse_ble_target("zz:zz:zz:zz:zz:zz") is None
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_mac_addresses(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid MAC address format (Linux/Windows)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid MAC addresses should be accepted and normalized to uppercase
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("ed:4d:9e:95:cf:60") == "ED:4D:9E:95:CF:60"
|
||||
assert mesh._parse_ble_target("AA:BB:CC:DD:EE:FF") == "AA:BB:CC:DD:EE:FF"
|
||||
assert mesh._parse_ble_target("00:11:22:33:44:55") == "00:11:22:33:44:55"
|
||||
|
||||
# With whitespace
|
||||
assert mesh._parse_ble_target(" ED:4D:9E:95:CF:60 ") == "ED:4D:9E:95:CF:60"
|
||||
|
||||
# Invalid MAC addresses should be rejected
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF") is None # Too short
|
||||
assert mesh._parse_ble_target("ED:4D:9E:95:CF:60:AB") is None # Too long
|
||||
assert mesh._parse_ble_target("GG:HH:II:JJ:KK:LL") is None # Invalid hex
|
||||
|
||||
|
||||
def test_parse_ble_target_accepts_uuids(mesh_module):
|
||||
"""Test that _parse_ble_target accepts valid UUID format (macOS)."""
|
||||
mesh = mesh_module
|
||||
|
||||
# Valid UUIDs should be accepted and normalized to uppercase
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("c0aea92f-045e-9b82-c9a6-a1fd822b3a9e")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
assert (
|
||||
mesh._parse_ble_target("12345678-1234-5678-9ABC-DEF012345678")
|
||||
== "12345678-1234-5678-9ABC-DEF012345678"
|
||||
)
|
||||
|
||||
# With whitespace
|
||||
assert (
|
||||
mesh._parse_ble_target(" C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E ")
|
||||
== "C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E"
|
||||
)
|
||||
|
||||
# Invalid UUIDs should be rejected
|
||||
assert mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6") is None # Too short
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F-045E-9B82-C9A6-A1FD822B3A9E-EXTRA") is None
|
||||
) # Too long
|
||||
assert (
|
||||
mesh._parse_ble_target("GGGGGGGG-GGGG-GGGG-GGGG-GGGGGGGGGGGG") is None
|
||||
) # Invalid hex
|
||||
assert (
|
||||
mesh._parse_ble_target("C0AEA92F:045E:9B82:C9A6:A1FD822B3A9E") is None
|
||||
) # Wrong separator
|
||||
|
||||
|
||||
def test_parse_network_target_additional_cases(mesh_module):
|
||||
mesh = mesh_module
|
||||
|
||||
|
||||
@@ -110,11 +110,20 @@ module PotatoMesh
|
||||
["!#{canonical_hex}", parsed, short_id]
|
||||
end
|
||||
|
||||
def broadcast_node_ref?(node_ref, fallback_num = nil)
|
||||
return true if fallback_num == 0xFFFFFFFF
|
||||
trimmed = string_or_nil(node_ref)
|
||||
return false unless trimmed
|
||||
normalized = trimmed.delete_prefix("!").strip.downcase
|
||||
normalized == "ffffffff"
|
||||
end
|
||||
|
||||
def ensure_unknown_node(db, node_ref, fallback_num = nil, heard_time: nil)
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
return unless parts
|
||||
|
||||
node_id, node_num, short_id = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
|
||||
existing = db.get_first_value(
|
||||
"SELECT 1 FROM nodes WHERE node_id = ? LIMIT 1",
|
||||
@@ -158,7 +167,10 @@ module PotatoMesh
|
||||
node_id = nil
|
||||
|
||||
parts = canonical_node_parts(node_ref, fallback_num)
|
||||
node_id, = parts if parts
|
||||
if parts
|
||||
node_id, node_num = parts
|
||||
return if broadcast_node_ref?(node_id, node_num)
|
||||
end
|
||||
|
||||
unless node_id
|
||||
trimmed = string_or_nil(node_ref)
|
||||
@@ -170,6 +182,7 @@ module PotatoMesh
|
||||
end
|
||||
end
|
||||
|
||||
return if broadcast_node_ref?(node_id, fallback_num)
|
||||
return unless node_id
|
||||
|
||||
updated = false
|
||||
@@ -1427,6 +1440,17 @@ module PotatoMesh
|
||||
source: :message,
|
||||
)
|
||||
|
||||
ensure_unknown_node(db, to_id || raw_to_id, message["to_num"], heard_time: rx_time) if to_id || raw_to_id
|
||||
if to_id || raw_to_id || message.key?("to_num")
|
||||
touch_node_last_seen(
|
||||
db,
|
||||
to_id || raw_to_id || message["to_num"],
|
||||
message["to_num"],
|
||||
rx_time: rx_time,
|
||||
source: :message,
|
||||
)
|
||||
end
|
||||
|
||||
lora_freq = coerce_integer(message["lora_freq"] || message["loraFrequency"])
|
||||
modem_preset = string_or_nil(message["modem_preset"] || message["modemPreset"])
|
||||
channel_name = string_or_nil(message["channel_name"] || message["channelName"])
|
||||
|
||||
@@ -116,6 +116,17 @@ module PotatoMesh
|
||||
coerced
|
||||
end
|
||||
|
||||
# Normalise a caller-supplied timestamp for API pagination windows.
|
||||
#
|
||||
# @param since [Object] requested lower bound expressed as seconds since the epoch.
|
||||
# @param floor [Integer] minimum allowable timestamp used to clamp the value.
|
||||
# @return [Integer] non-negative timestamp greater than or equal to +floor+.
|
||||
def normalize_since_threshold(since, floor: 0)
|
||||
threshold = coerce_integer(since)
|
||||
threshold = 0 if threshold.nil? || threshold.negative?
|
||||
[threshold, floor].max
|
||||
end
|
||||
|
||||
def node_reference_tokens(node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
|
||||
@@ -198,12 +209,19 @@ module PotatoMesh
|
||||
["(#{clauses.join(" OR ")})", params]
|
||||
end
|
||||
|
||||
def query_nodes(limit, node_ref: nil)
|
||||
# Fetch node state optionally scoped by identifier and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted node rows suitable for API responses.
|
||||
def query_nodes(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_last_heard = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_last_heard)
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
@@ -214,7 +232,7 @@ module PotatoMesh
|
||||
params.concat(clause.last)
|
||||
else
|
||||
where_clauses << "last_heard >= ?"
|
||||
params << min_last_heard
|
||||
params << since_threshold
|
||||
end
|
||||
|
||||
if private_mode?
|
||||
@@ -242,7 +260,7 @@ module PotatoMesh
|
||||
.map { |value| coerce_integer(value) }
|
||||
.compact
|
||||
.max
|
||||
last_candidate && last_candidate >= min_last_heard
|
||||
last_candidate && last_candidate >= since_threshold
|
||||
end
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
@@ -262,12 +280,18 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_ingestors(limit)
|
||||
# Fetch ingestor heartbeats with optional freshness filtering.
|
||||
#
|
||||
# @param limit [Integer] maximum number of ingestors to return.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
|
||||
def query_ingestors(limit, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
cutoff = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: cutoff)
|
||||
sql = <<~SQL
|
||||
SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset
|
||||
FROM ingestors
|
||||
@@ -276,7 +300,7 @@ module PotatoMesh
|
||||
LIMIT ?
|
||||
SQL
|
||||
|
||||
rows = db.execute(sql, [cutoff, limit])
|
||||
rows = db.execute(sql, [since_threshold, limit])
|
||||
rows.each do |row|
|
||||
row.delete_if { |key, _| key.is_a?(Integer) }
|
||||
start_time = coerce_integer(row["start_time"])
|
||||
@@ -306,8 +330,7 @@ module PotatoMesh
|
||||
# @return [Array<Hash>] compacted message rows safe for API responses.
|
||||
def query_messages(limit, node_ref: nil, include_encrypted: false, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
since_threshold = coerce_integer(since)
|
||||
since_threshold = 0 if since_threshold.nil? || since_threshold.negative?
|
||||
since_threshold = normalize_since_threshold(since, floor: 0)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
@@ -385,7 +408,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_positions(limit, node_ref: nil)
|
||||
# Fetch positions optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted position rows suitable for API responses.
|
||||
def query_positions(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -393,8 +422,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -436,7 +466,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_neighbors(limit, node_ref: nil)
|
||||
# Fetch neighbor relationships optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
|
||||
def query_neighbors(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -444,8 +480,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
|
||||
@@ -476,7 +513,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_telemetry(limit, node_ref: nil)
|
||||
# Fetch telemetry packets optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
|
||||
def query_telemetry(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -484,8 +527,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
@@ -555,7 +599,13 @@ module PotatoMesh
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:)
|
||||
# Aggregate telemetry metrics into time buckets.
|
||||
#
|
||||
# @param window_seconds [Integer] duration expressed in seconds to include in the query.
|
||||
# @param bucket_seconds [Integer] size of each aggregation bucket in seconds.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the requested window.
|
||||
# @return [Array<Hash>] aggregated telemetry metrics grouped by bucket start time.
|
||||
def query_telemetry_buckets(window_seconds:, bucket_seconds:, since: 0)
|
||||
window = coerce_integer(window_seconds) || DEFAULT_TELEMETRY_WINDOW_SECONDS
|
||||
window = DEFAULT_TELEMETRY_WINDOW_SECONDS if window <= 0
|
||||
bucket = coerce_integer(bucket_seconds) || DEFAULT_TELEMETRY_BUCKET_SECONDS
|
||||
@@ -565,6 +615,7 @@ module PotatoMesh
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_timestamp = now - window
|
||||
since_threshold = normalize_since_threshold(since, floor: min_timestamp)
|
||||
bucket_expression = "((COALESCE(rx_time, telemetry_time) / ?) * ?)"
|
||||
select_clauses = [
|
||||
"#{bucket_expression} AS bucket_start",
|
||||
@@ -590,7 +641,7 @@ module PotatoMesh
|
||||
ORDER BY bucket_start ASC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params = [bucket, bucket, min_timestamp, MAX_QUERY_LIMIT]
|
||||
params = [bucket, bucket, since_threshold, MAX_QUERY_LIMIT]
|
||||
rows = db.execute(sql, params)
|
||||
rows.map do |row|
|
||||
bucket_start = coerce_integer(row["bucket_start"])
|
||||
@@ -670,7 +721,13 @@ module PotatoMesh
|
||||
column
|
||||
end
|
||||
|
||||
def query_traces(limit, node_ref: nil)
|
||||
# Fetch trace records optionally scoped by node and timestamp.
|
||||
#
|
||||
# @param limit [Integer] maximum number of rows to return.
|
||||
# @param node_ref [String, Integer, nil] optional node reference to scope results.
|
||||
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
|
||||
# @return [Array<Hash>] compacted trace rows suitable for API responses.
|
||||
def query_traces(limit, node_ref: nil, since: 0)
|
||||
limit = coerce_query_limit(limit)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
@@ -678,8 +735,9 @@ module PotatoMesh
|
||||
where_clauses = []
|
||||
now = Time.now.to_i
|
||||
min_rx_time = now - PotatoMesh::Config.week_seconds
|
||||
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
|
||||
where_clauses << "COALESCE(rx_time, 0) >= ?"
|
||||
params << min_rx_time
|
||||
params << since_threshold
|
||||
|
||||
if node_ref
|
||||
tokens = node_reference_tokens(node_ref)
|
||||
|
||||
@@ -64,7 +64,7 @@ module PotatoMesh
|
||||
app.get "/api/nodes" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_nodes(limit).to_json
|
||||
query_nodes(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/nodes/:id" do
|
||||
@@ -72,7 +72,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
rows = query_nodes(limit, node_ref: node_ref)
|
||||
rows = query_nodes(limit, node_ref: node_ref, since: params["since"])
|
||||
halt 404, { error: "not found" }.to_json if rows.empty?
|
||||
rows.first.to_json
|
||||
end
|
||||
@@ -80,7 +80,7 @@ module PotatoMesh
|
||||
app.get "/api/ingestors" do
|
||||
content_type :json
|
||||
limit = coerce_query_limit(params["limit"])
|
||||
query_ingestors(limit).to_json
|
||||
query_ingestors(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/messages" do
|
||||
@@ -111,7 +111,7 @@ module PotatoMesh
|
||||
app.get "/api/positions" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit).to_json
|
||||
query_positions(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/positions/:id" do
|
||||
@@ -119,13 +119,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit, node_ref: node_ref).to_json
|
||||
query_positions(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit).to_json
|
||||
query_neighbors(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors/:id" do
|
||||
@@ -133,13 +133,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit, node_ref: node_ref).to_json
|
||||
query_neighbors(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit).to_json
|
||||
query_telemetry(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/aggregated" do
|
||||
@@ -170,7 +170,11 @@ module PotatoMesh
|
||||
halt 400, { error: "bucketSeconds too small for requested window" }.to_json
|
||||
end
|
||||
|
||||
query_telemetry_buckets(window_seconds: window_seconds, bucket_seconds: bucket_seconds).to_json
|
||||
query_telemetry_buckets(
|
||||
window_seconds: window_seconds,
|
||||
bucket_seconds: bucket_seconds,
|
||||
since: params["since"],
|
||||
).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/:id" do
|
||||
@@ -178,13 +182,13 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit, node_ref: node_ref).to_json
|
||||
query_telemetry(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit).to_json
|
||||
query_traces(limit, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/traces/:id" do
|
||||
@@ -192,7 +196,7 @@ module PotatoMesh
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_traces(limit, node_ref: node_ref).to_json
|
||||
query_traces(limit, node_ref: node_ref, since: params["since"]).to_json
|
||||
end
|
||||
|
||||
app.get "/api/instances" do
|
||||
|
||||
@@ -42,6 +42,7 @@ module PotatoMesh
|
||||
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY = 128
|
||||
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS = 120
|
||||
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze
|
||||
|
||||
# Retrieve the configured API token used for authenticated requests.
|
||||
#
|
||||
@@ -175,7 +176,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"0.5.8"
|
||||
"0.5.9"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
@@ -409,7 +410,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [Array<String>] list of default seed domains.
|
||||
def federation_seed_domains
|
||||
["potatomesh.net"].freeze
|
||||
DEFAULT_FEDERATION_SEED_DOMAINS
|
||||
end
|
||||
|
||||
# Determine how often we broadcast federation announcements.
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"uplot": "^1.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -154,6 +158,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/uplot": {
|
||||
"version": "1.6.32",
|
||||
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
||||
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
|
||||
+5
-1
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.8",
|
||||
"version": "0.5.9",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "node ./scripts/copy-uplot.js",
|
||||
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"uplot": "^1.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
||||
@@ -80,13 +80,19 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail
|
||||
},
|
||||
]);
|
||||
let receivedOptions = null;
|
||||
const renderCharts = (node, options) => {
|
||||
let mountedModels = null;
|
||||
const createCharts = (node, options) => {
|
||||
receivedOptions = options;
|
||||
return '<section class="node-detail__charts">Charts</section>';
|
||||
return { chartsHtml: '<section class="node-detail__charts">Charts</section>', chartModels: [{ id: 'power' }] };
|
||||
};
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const mountCharts = (chartModels, options) => {
|
||||
mountedModels = { chartModels, options };
|
||||
return [];
|
||||
};
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts, mountCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
|
||||
assert.equal(mountedModels.chartModels.length, 1);
|
||||
assert.ok(receivedOptions);
|
||||
assert.equal(receivedOptions.chartOptions.windowMs, 604_800_000);
|
||||
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
|
||||
@@ -118,8 +124,8 @@ test('initializeChartsPage shows an error message when fetching fails', async ()
|
||||
const fetchImpl = async () => {
|
||||
throw new Error('network');
|
||||
};
|
||||
const renderCharts = () => '<section>unused</section>';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '<section>unused</section>', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, false);
|
||||
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
|
||||
});
|
||||
@@ -136,8 +142,8 @@ test('initializeChartsPage handles missing containers and empty telemetry snapsh
|
||||
},
|
||||
};
|
||||
const fetchImpl = async () => createResponse(200, []);
|
||||
const renderCharts = () => '';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
|
||||
});
|
||||
@@ -155,8 +161,8 @@ test('initializeChartsPage shows a status when rendering produces no markup', as
|
||||
aggregates: { voltage: { avg: 3.9 } },
|
||||
},
|
||||
]);
|
||||
const renderCharts = () => '';
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
|
||||
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
|
||||
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
|
||||
assert.equal(result, true);
|
||||
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { resolveLegendVisibility } from '../map-legend-visibility.js';
|
||||
|
||||
test('resolveLegendVisibility hides when a default collapse is requested', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: false }), false);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: true, mediaQueryMatches: true }), false);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility hides for dashboard and map views', () => {
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'dashboard' }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false, viewMode: 'map' }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLegendVisibility follows the media query when not forced', () => {
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: false }), true);
|
||||
assert.equal(resolveLegendVisibility({ defaultCollapsed: false, mediaQueryMatches: true }), false);
|
||||
});
|
||||
@@ -111,6 +111,26 @@ test('createNodeDetailOverlayManager renders fetched markup and restores focus',
|
||||
assert.equal(focusTarget.focusCalled, true);
|
||||
});
|
||||
|
||||
test('createNodeDetailOverlayManager mounts telemetry charts for overlay content', async () => {
|
||||
const { document, content } = createOverlayHarness();
|
||||
const chartModels = [{ id: 'power' }];
|
||||
let mountCall = null;
|
||||
const manager = createNodeDetailOverlayManager({
|
||||
document,
|
||||
fetchNodeDetail: async () => ({ html: '<section class="node-detail">Charts</section>', chartModels }),
|
||||
mountCharts: (models, options) => {
|
||||
mountCall = { models, options };
|
||||
return [];
|
||||
},
|
||||
});
|
||||
assert.ok(manager);
|
||||
await manager.open({ nodeId: '!alpha' });
|
||||
assert.equal(content.innerHTML.includes('Charts'), true);
|
||||
assert.ok(mountCall);
|
||||
assert.equal(mountCall.models, chartModels);
|
||||
assert.equal(mountCall.options.root, content);
|
||||
});
|
||||
|
||||
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
|
||||
const { document, overlay, content } = createOverlayHarness();
|
||||
const errors = [];
|
||||
|
||||
@@ -47,7 +47,9 @@ const {
|
||||
categoriseNeighbors,
|
||||
renderNeighborGroups,
|
||||
renderSingleNodeTable,
|
||||
createTelemetryCharts,
|
||||
renderTelemetryCharts,
|
||||
buildUPlotChartConfig,
|
||||
renderMessages,
|
||||
renderTraceroutes,
|
||||
renderTracePath,
|
||||
@@ -386,23 +388,93 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
},
|
||||
};
|
||||
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 (%)'), true);
|
||||
assert.equal(html.includes('Voltage (V)'), true);
|
||||
assert.equal(html.includes('Current (A)'), 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 (\u03a9)'), true);
|
||||
assert.equal(html.includes('Air quality'), true);
|
||||
assert.equal(html.includes('IAQ index'), true);
|
||||
assert.equal(html.includes('Temperature (\u00b0C)'), true);
|
||||
assert.equal(html.includes(expectedDate), true);
|
||||
assert.equal(html.includes('node-detail__chart-point'), true);
|
||||
assert.equal(html.includes('node-detail__chart-plot'), true);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 120,
|
||||
device_metrics: {
|
||||
battery_level: 90,
|
||||
voltage: 7.2,
|
||||
current: 3.6,
|
||||
channel_utilization: 45,
|
||||
air_util_tx: 18,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 45,
|
||||
relative_humidity: 48,
|
||||
barometric_pressure: 1250,
|
||||
gas_resistance: 1200,
|
||||
iaq: 650,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const environmentChart = chartModels.find(model => model.id === 'environment');
|
||||
const airChart = chartModels.find(model => model.id === 'airQuality');
|
||||
const powerConfig = buildUPlotChartConfig(powerChart);
|
||||
const envConfig = buildUPlotChartConfig(environmentChart);
|
||||
const airConfig = buildUPlotChartConfig(airChart);
|
||||
assert.equal(powerConfig.options.scales.voltage.range()[1], 7.2);
|
||||
assert.equal(powerConfig.options.scales.current.range()[1], 3.6);
|
||||
assert.equal(envConfig.options.scales.temperature.range()[1], 45);
|
||||
assert.equal(airConfig.options.scales.iaq.range()[1], 650);
|
||||
assert.equal(airConfig.options.scales.pressure.range()[1], 1100);
|
||||
});
|
||||
|
||||
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const node = {
|
||||
rawSources: {
|
||||
telemetry: {
|
||||
snapshots: [
|
||||
{
|
||||
rx_time: nowSeconds - 180,
|
||||
device_metrics: {
|
||||
battery_level: 70,
|
||||
voltage: 4.5,
|
||||
current: 1.5,
|
||||
channel_utilization: 35,
|
||||
air_util_tx: 15,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 25,
|
||||
relative_humidity: 50,
|
||||
barometric_pressure: 1015,
|
||||
gas_resistance: 1500,
|
||||
iaq: 200,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const environmentChart = chartModels.find(model => model.id === 'environment');
|
||||
const airChart = chartModels.find(model => model.id === 'airQuality');
|
||||
const powerConfig = buildUPlotChartConfig(powerChart);
|
||||
const envConfig = buildUPlotChartConfig(environmentChart);
|
||||
const airConfig = buildUPlotChartConfig(airChart);
|
||||
assert.equal(powerConfig.options.scales.voltage.range()[1], 6);
|
||||
assert.equal(powerConfig.options.scales.current.range()[1], 3);
|
||||
assert.equal(envConfig.options.scales.temperature.range()[1], 40);
|
||||
assert.equal(airConfig.options.scales.iaq.range()[1], 500);
|
||||
});
|
||||
|
||||
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
|
||||
@@ -518,17 +590,18 @@ test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
|
||||
neighbors: [],
|
||||
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
|
||||
});
|
||||
const html = await fetchNodeDetailHtml(reference, {
|
||||
const result = await fetchNodeDetailHtml(reference, {
|
||||
refreshImpl,
|
||||
fetchImpl,
|
||||
renderShortHtml: short => `<span class="short-name">${short}</span>`,
|
||||
returnState: true,
|
||||
});
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/messages/!alpha')), true);
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/traces/!alpha')), true);
|
||||
assert.equal(html.includes('Example Alpha'), true);
|
||||
assert.equal(html.includes('Overlay hello'), true);
|
||||
assert.equal(html.includes('Traceroutes'), true);
|
||||
assert.equal(html.includes('node-detail__table'), true);
|
||||
assert.equal(result.html.includes('Example Alpha'), true);
|
||||
assert.equal(result.html.includes('Overlay hello'), true);
|
||||
assert.equal(result.html.includes('Traceroutes'), true);
|
||||
assert.equal(result.html.includes('node-detail__table'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async () => {
|
||||
@@ -566,16 +639,17 @@ test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async ()
|
||||
rawSources: { node: { node_id: '!origin', role: 'CLIENT', short_name: 'ORIG' } },
|
||||
});
|
||||
|
||||
const html = await fetchNodeDetailHtml(reference, {
|
||||
const result = await fetchNodeDetailHtml(reference, {
|
||||
refreshImpl,
|
||||
fetchImpl,
|
||||
renderShortHtml: short => `<span class="short-name">${short}</span>`,
|
||||
returnState: true,
|
||||
});
|
||||
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!relay')), true);
|
||||
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!target')), true);
|
||||
assert.equal(html.includes('RLY1'), true);
|
||||
assert.equal(html.includes('TGT1'), true);
|
||||
assert.equal(result.html.includes('RLY1'), true);
|
||||
assert.equal(result.html.includes('TGT1'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/*
|
||||
* 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 { __testUtils } from '../node-page.js';
|
||||
import { buildMovingAverageSeries } from '../charts-page.js';
|
||||
|
||||
const {
|
||||
createTelemetryCharts,
|
||||
buildUPlotChartConfig,
|
||||
mountTelemetryCharts,
|
||||
mountTelemetryChartsWithRetry,
|
||||
} = __testUtils;
|
||||
|
||||
test('uPlot chart config preserves axes, colors, and tick labels for node telemetry', () => {
|
||||
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,
|
||||
current: 0.75,
|
||||
},
|
||||
},
|
||||
{
|
||||
rx_time: nowSeconds - 3_600,
|
||||
device_metrics: {
|
||||
battery_level: 78,
|
||||
voltage: 4.05,
|
||||
current: 0.65,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, {
|
||||
nowMs,
|
||||
chartOptions: {
|
||||
xAxisTickBuilder: () => [nowMs],
|
||||
xAxisTickFormatter: () => '08',
|
||||
},
|
||||
});
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options, data } = buildUPlotChartConfig(powerChart);
|
||||
|
||||
assert.deepEqual(options.scales.battery.range(), [0, 100]);
|
||||
assert.deepEqual(options.scales.voltage.range(), [0, 6]);
|
||||
assert.deepEqual(options.scales.current.range(), [0, 3]);
|
||||
assert.equal(options.series[1].stroke, '#8856a7');
|
||||
assert.equal(options.series[2].stroke, '#9ebcda');
|
||||
assert.equal(options.series[3].stroke, '#3182bd');
|
||||
assert.deepEqual(options.axes[0].values(null, [nowMs]), ['08']);
|
||||
assert.equal(options.axes[0].stroke, '#5c6773');
|
||||
|
||||
assert.deepEqual(data[0].slice(0, 2), [nowMs - 3_600_000, nowMs - 60_000]);
|
||||
assert.deepEqual(data[1].slice(0, 2), [78, 80]);
|
||||
});
|
||||
|
||||
test('uPlot chart config maps moving averages and raw points for aggregated telemetry', () => {
|
||||
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
|
||||
const nowSeconds = Math.floor(nowMs / 1000);
|
||||
const snapshots = [
|
||||
{
|
||||
rx_time: nowSeconds - 3_600,
|
||||
device_metrics: { battery_level: 10 },
|
||||
},
|
||||
{
|
||||
rx_time: nowSeconds - 1_800,
|
||||
device_metrics: { battery_level: 20 },
|
||||
},
|
||||
];
|
||||
const node = { rawSources: { telemetry: { snapshots } } };
|
||||
const { chartModels } = createTelemetryCharts(node, {
|
||||
nowMs,
|
||||
chartOptions: {
|
||||
lineReducer: points => buildMovingAverageSeries(points, 3_600_000),
|
||||
},
|
||||
});
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options, data } = buildUPlotChartConfig(powerChart);
|
||||
|
||||
assert.equal(options.series.length, 3);
|
||||
assert.equal(options.series[1].stroke.startsWith('rgba('), true);
|
||||
assert.equal(options.series[2].stroke, '#8856a7');
|
||||
assert.deepEqual(data[1].slice(0, 2), [10, 15]);
|
||||
assert.deepEqual(data[2].slice(0, 2), [10, 20]);
|
||||
});
|
||||
|
||||
test('buildUPlotChartConfig applies axis color overrides', () => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const powerChart = chartModels.find(model => model.id === 'power');
|
||||
const { options } = buildUPlotChartConfig(powerChart, {
|
||||
axisColor: '#ffffff',
|
||||
gridColor: '#222222',
|
||||
});
|
||||
assert.equal(options.axes[0].stroke, '#ffffff');
|
||||
assert.equal(options.axes[0].grid.stroke, '#222222');
|
||||
});
|
||||
|
||||
test('environment chart renders humidity axis on the right side', () => {
|
||||
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,
|
||||
environment_metrics: {
|
||||
temperature: 19.5,
|
||||
relative_humidity: 55,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const envChart = chartModels.find(model => model.id === 'environment');
|
||||
const { options } = buildUPlotChartConfig(envChart);
|
||||
const humidityAxis = options.axes.find(axis => axis.scale === 'humidity');
|
||||
assert.ok(humidityAxis);
|
||||
assert.equal(humidityAxis.side, 1);
|
||||
assert.equal(humidityAxis.show, true);
|
||||
});
|
||||
|
||||
test('channel utilization chart includes a right-side utilization axis', () => {
|
||||
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: {
|
||||
channel_utilization: 40,
|
||||
air_util_tx: 22,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const channelChart = chartModels.find(model => model.id === 'channel');
|
||||
const { options } = buildUPlotChartConfig(channelChart);
|
||||
const rightAxis = options.axes.find(axis => axis.scale === 'channelSecondary');
|
||||
assert.ok(rightAxis);
|
||||
assert.equal(rightAxis.side, 1);
|
||||
assert.equal(rightAxis.show, true);
|
||||
});
|
||||
|
||||
test('createTelemetryCharts returns empty markup when snapshots are missing', () => {
|
||||
const { chartsHtml, chartModels } = createTelemetryCharts({ rawSources: { telemetry: { snapshots: [] } } });
|
||||
assert.equal(chartsHtml, '');
|
||||
assert.equal(chartModels.length, 0);
|
||||
});
|
||||
|
||||
test('mountTelemetryCharts instantiates uPlot for chart containers', () => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = { innerHTML: 'placeholder' };
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
}
|
||||
}
|
||||
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
|
||||
assert.equal(plotRoot.innerHTML, '');
|
||||
assert.equal(instances.length, 1);
|
||||
assert.equal(instances[0].container, plotRoot);
|
||||
});
|
||||
|
||||
test('mountTelemetryCharts responds to window resize events', async () => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = {
|
||||
innerHTML: '',
|
||||
clientWidth: 320,
|
||||
clientHeight: 180,
|
||||
getBoundingClientRect() {
|
||||
return { width: this.clientWidth, height: this.clientHeight };
|
||||
},
|
||||
};
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
const previousResizeObserver = globalThis.ResizeObserver;
|
||||
const previousAddEventListener = globalThis.addEventListener;
|
||||
let resizeHandler = null;
|
||||
globalThis.ResizeObserver = undefined;
|
||||
globalThis.addEventListener = (event, handler) => {
|
||||
if (event === 'resize') {
|
||||
resizeHandler = handler;
|
||||
}
|
||||
};
|
||||
const sizeCalls = [];
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
this.root = container;
|
||||
}
|
||||
setSize(size) {
|
||||
sizeCalls.push(size);
|
||||
}
|
||||
}
|
||||
mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
|
||||
assert.ok(resizeHandler);
|
||||
plotRoot.clientWidth = 480;
|
||||
plotRoot.clientHeight = 240;
|
||||
resizeHandler();
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
assert.equal(sizeCalls.length >= 1, true);
|
||||
assert.deepEqual(sizeCalls[sizeCalls.length - 1], { width: 480, height: 240 });
|
||||
globalThis.ResizeObserver = previousResizeObserver;
|
||||
globalThis.addEventListener = previousAddEventListener;
|
||||
});
|
||||
|
||||
test('mountTelemetryChartsWithRetry loads uPlot when missing', async () => {
|
||||
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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { chartModels } = createTelemetryCharts(node, { nowMs });
|
||||
const [model] = chartModels;
|
||||
const plotRoot = { innerHTML: '', clientWidth: 400, clientHeight: 200 };
|
||||
const chartContainer = {
|
||||
querySelector(selector) {
|
||||
return selector === '[data-telemetry-plot]' ? plotRoot : null;
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
ownerDocument: {
|
||||
body: {},
|
||||
querySelector: () => null,
|
||||
},
|
||||
querySelector(selector) {
|
||||
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
|
||||
},
|
||||
};
|
||||
const previousUPlot = globalThis.uPlot;
|
||||
const instances = [];
|
||||
class UPlotStub {
|
||||
constructor(options, data, container) {
|
||||
this.options = options;
|
||||
this.data = data;
|
||||
this.container = container;
|
||||
instances.push(this);
|
||||
}
|
||||
}
|
||||
let loadCalled = false;
|
||||
const loadUPlot = ({ onLoad }) => {
|
||||
loadCalled = true;
|
||||
globalThis.uPlot = UPlotStub;
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
mountTelemetryChartsWithRetry(chartModels, { root, loadUPlot });
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
assert.equal(loadCalled, true);
|
||||
assert.equal(instances.length, 1);
|
||||
globalThis.uPlot = previousUPlot;
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderTelemetryCharts } from './node-page.js';
|
||||
import { createTelemetryCharts, mountTelemetryChartsWithRetry } from './node-page.js';
|
||||
|
||||
const TELEMETRY_BUCKET_SECONDS = 60 * 60;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
@@ -193,6 +193,21 @@ export async function fetchAggregatedTelemetry({
|
||||
.filter(snapshot => snapshot != null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and render aggregated telemetry charts.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* rootId?: string,
|
||||
* fetchImpl?: Function,
|
||||
* bucketSeconds?: number,
|
||||
* windowMs?: number,
|
||||
* createCharts?: Function,
|
||||
* mountCharts?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when charts were rendered successfully.
|
||||
*/
|
||||
export async function initializeChartsPage(options = {}) {
|
||||
const documentRef = options.document ?? globalThis.document;
|
||||
if (!documentRef || typeof documentRef.getElementById !== 'function') {
|
||||
@@ -204,7 +219,8 @@ export async function initializeChartsPage(options = {}) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
|
||||
const createCharts = typeof options.createCharts === 'function' ? options.createCharts : createTelemetryCharts;
|
||||
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
const bucketSeconds = options.bucketSeconds ?? TELEMETRY_BUCKET_SECONDS;
|
||||
const windowMs = options.windowMs ?? CHART_WINDOW_MS;
|
||||
@@ -218,7 +234,7 @@ export async function initializeChartsPage(options = {}) {
|
||||
return true;
|
||||
}
|
||||
const node = { rawSources: { telemetry: { snapshots } } };
|
||||
const chartsHtml = renderCharts(node, {
|
||||
const chartState = createCharts(node, {
|
||||
nowMs: Date.now(),
|
||||
chartOptions: {
|
||||
windowMs,
|
||||
@@ -228,11 +244,12 @@ export async function initializeChartsPage(options = {}) {
|
||||
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
|
||||
},
|
||||
});
|
||||
if (!chartsHtml) {
|
||||
if (!chartState.chartsHtml) {
|
||||
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
|
||||
return true;
|
||||
}
|
||||
container.innerHTML = chartsHtml;
|
||||
container.innerHTML = chartState.chartsHtml;
|
||||
mountCharts(chartState.chartModels, { root: container, uPlotImpl: options.uPlotImpl });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to render aggregated telemetry charts', error);
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { resolveLegendVisibility } from './map-legend-visibility.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';
|
||||
@@ -116,6 +117,7 @@ export function initializeApp(config) {
|
||||
: false;
|
||||
const isDashboardView = bodyClassList ? bodyClassList.contains('view-dashboard') : false;
|
||||
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
|
||||
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
|
||||
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
|
||||
/**
|
||||
* Column sorter configuration for the node table.
|
||||
@@ -435,6 +437,7 @@ export function initializeApp(config) {
|
||||
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
|
||||
const fullscreenContainer = mapPanel || mapContainer;
|
||||
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
|
||||
const legendDefaultCollapsed = mapPanel ? mapPanel.dataset.legendCollapsed === 'true' : false;
|
||||
let mapStatusEl = null;
|
||||
let map = null;
|
||||
let mapCenterLatLng = null;
|
||||
@@ -1526,8 +1529,14 @@ export function initializeApp(config) {
|
||||
legendToggleControl.addTo(map);
|
||||
|
||||
const legendMediaQuery = window.matchMedia('(max-width: 1024px)');
|
||||
setLegendVisibility(!legendMediaQuery.matches);
|
||||
const initialLegendVisible = resolveLegendVisibility({
|
||||
defaultCollapsed: legendDefaultCollapsed,
|
||||
mediaQueryMatches: legendMediaQuery.matches,
|
||||
viewMode: isDashboardView ? 'dashboard' : (isMapView ? 'map' : undefined)
|
||||
});
|
||||
setLegendVisibility(initialLegendVisible);
|
||||
legendMediaQuery.addEventListener('change', event => {
|
||||
if (legendDefaultCollapsed || isDashboardView || isMapView) return;
|
||||
setLegendVisibility(!event.matches);
|
||||
});
|
||||
} else if (mapContainer && !hasLeaflet) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolve the initial visibility of the map legend.
|
||||
*
|
||||
* @param {{ defaultCollapsed: boolean, mediaQueryMatches: boolean, viewMode?: string }} options
|
||||
* @returns {boolean} True when the legend should be visible.
|
||||
*/
|
||||
export function resolveLegendVisibility({ defaultCollapsed, mediaQueryMatches, viewMode }) {
|
||||
if (defaultCollapsed || viewMode === 'dashboard' || viewMode === 'map') return false;
|
||||
return !mediaQueryMatches;
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { fetchNodeDetailHtml } from './node-page.js';
|
||||
import { fetchNodeDetailHtml, mountTelemetryChartsWithRetry } from './node-page.js';
|
||||
|
||||
/**
|
||||
* Escape a string for safe HTML injection.
|
||||
@@ -68,6 +68,9 @@ function hasValidReference(reference) {
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* mountCharts?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* loadUPlot?: Function,
|
||||
* privateMode?: boolean,
|
||||
* logger?: Console
|
||||
* }} [options] Behaviour overrides.
|
||||
@@ -101,6 +104,9 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
const fetchImpl = options.fetchImpl;
|
||||
const refreshImpl = options.refreshImpl;
|
||||
const renderShortHtml = options.renderShortHtml;
|
||||
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
|
||||
const uPlotImpl = options.uPlotImpl;
|
||||
const loadUPlot = options.loadUPlot;
|
||||
|
||||
let requestToken = 0;
|
||||
let lastTrigger = null;
|
||||
@@ -198,16 +204,21 @@ export function createNodeDetailOverlayManager(options = {}) {
|
||||
}
|
||||
const currentToken = ++requestToken;
|
||||
try {
|
||||
const html = await fetchDetail(reference, {
|
||||
const result = await fetchDetail(reference, {
|
||||
fetchImpl,
|
||||
refreshImpl,
|
||||
renderShortHtml,
|
||||
privateMode,
|
||||
returnState: true,
|
||||
});
|
||||
if (currentToken !== requestToken) {
|
||||
return;
|
||||
}
|
||||
content.innerHTML = html;
|
||||
const resolvedHtml = typeof result === 'string' ? result : result?.html;
|
||||
content.innerHTML = resolvedHtml ?? '';
|
||||
if (result && typeof result === 'object' && Array.isArray(result.chartModels)) {
|
||||
mountCharts(result.chartModels, { root: content, uPlotImpl, loadUPlot });
|
||||
}
|
||||
if (typeof closeButton.focus === 'function') {
|
||||
closeButton.focus();
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 6,
|
||||
ticks: 3,
|
||||
color: '#9ebcda',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
@@ -77,6 +78,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 3,
|
||||
ticks: 3,
|
||||
color: '#3182bd',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -122,6 +124,15 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
ticks: 4,
|
||||
color: '#2ca25f',
|
||||
},
|
||||
{
|
||||
id: 'channelSecondary',
|
||||
position: 'right',
|
||||
label: 'Utilization (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: 4,
|
||||
color: '#2ca25f',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
@@ -135,7 +146,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
},
|
||||
{
|
||||
id: 'air',
|
||||
axis: 'channel',
|
||||
axis: 'channelSecondary',
|
||||
color: '#99d8c9',
|
||||
label: 'Air util tx',
|
||||
legend: 'Air util TX (%)',
|
||||
@@ -156,16 +167,17 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 40,
|
||||
ticks: 4,
|
||||
color: '#fc8d59',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
{
|
||||
id: 'humidity',
|
||||
position: 'left',
|
||||
position: 'right',
|
||||
label: 'Humidity (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: 4,
|
||||
color: '#91bfdb',
|
||||
visible: false,
|
||||
visible: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -220,6 +232,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
max: 500,
|
||||
ticks: 5,
|
||||
color: '#636363',
|
||||
allowUpperOverflow: true,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -853,67 +866,6 @@ function createChartDimensions(spec) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the horizontal drawing position for an axis descriptor.
|
||||
*
|
||||
* @param {string} position Axis position keyword.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} X coordinate for the axis baseline.
|
||||
*/
|
||||
function resolveAxisX(position, dims) {
|
||||
switch (position) {
|
||||
case 'leftSecondary':
|
||||
return dims.margin.left - 32;
|
||||
case 'right':
|
||||
return dims.width - dims.margin.right;
|
||||
case 'rightSecondary':
|
||||
return dims.width - dims.margin.right + 32;
|
||||
case 'left':
|
||||
default:
|
||||
return dims.margin.left;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the X coordinate for a timestamp constrained to the rolling window.
|
||||
*
|
||||
* @param {number} timestamp Timestamp in milliseconds.
|
||||
* @param {number} domainStart Start of the window in milliseconds.
|
||||
* @param {number} domainEnd End of the window in milliseconds.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} X coordinate inside the SVG viewport.
|
||||
*/
|
||||
function scaleTimestamp(timestamp, domainStart, domainEnd, dims) {
|
||||
const safeStart = Math.min(domainStart, domainEnd);
|
||||
const safeEnd = Math.max(domainStart, domainEnd);
|
||||
const span = Math.max(1, safeEnd - safeStart);
|
||||
const clamped = clamp(timestamp, safeStart, safeEnd);
|
||||
const ratio = (clamped - safeStart) / span;
|
||||
return dims.margin.left + ratio * dims.innerWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value bound to a specific axis into a Y coordinate.
|
||||
*
|
||||
* @param {number} value Series value.
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {number} Y coordinate.
|
||||
*/
|
||||
function scaleValueToAxis(value, axis, dims) {
|
||||
if (!axis) return dims.chartBottom;
|
||||
if (axis.scale === 'log') {
|
||||
const minLog = Math.log10(axis.min);
|
||||
const maxLog = Math.log10(axis.max);
|
||||
const safe = clamp(value, axis.min, axis.max);
|
||||
const ratio = (Math.log10(safe) - minLog) / (maxLog - minLog);
|
||||
return dims.chartBottom - ratio * dims.innerHeight;
|
||||
}
|
||||
const safe = clamp(value, axis.min, axis.max);
|
||||
const ratio = (safe - axis.min) / (axis.max - axis.min || 1);
|
||||
return dims.chartBottom - ratio * dims.innerHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect candidate containers that may hold telemetry values for a snapshot.
|
||||
*
|
||||
@@ -1005,161 +957,98 @@ function buildSeriesPoints(entries, fields, domainStart, domainEnd) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a telemetry series as circles plus an optional translucent guide line.
|
||||
*
|
||||
* @param {Object} seriesConfig Series metadata.
|
||||
* @param {Array<{timestamp: number, value: number}>} points Series points.
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @param {number} domainStart Window start timestamp.
|
||||
* @param {number} domainEnd Window end timestamp.
|
||||
* @returns {string} SVG markup for the series.
|
||||
*/
|
||||
function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd, { lineReducer } = {}) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const convertPoint = point => {
|
||||
const cx = scaleTimestamp(point.timestamp, domainStart, domainEnd, dims);
|
||||
const cy = scaleValueToAxis(point.value, axis, dims);
|
||||
return { cx, cy, value: point.value };
|
||||
};
|
||||
const circleEntries = points.map(point => {
|
||||
const coords = convertPoint(point);
|
||||
const tooltip = formatSeriesPointValue(seriesConfig, point.value);
|
||||
const titleMarkup = tooltip ? `<title>${escapeHtml(tooltip)}</title>` : '';
|
||||
return `<circle class="node-detail__chart-point" cx="${coords.cx.toFixed(2)}" cy="${coords.cy.toFixed(2)}" r="3.2" fill="${seriesConfig.color}" aria-hidden="true">${titleMarkup}</circle>`;
|
||||
});
|
||||
const lineSource = typeof lineReducer === 'function' ? lineReducer(points) : points;
|
||||
const linePoints = Array.isArray(lineSource) && lineSource.length > 0 ? lineSource : points;
|
||||
const coordinates = linePoints.map(convertPoint);
|
||||
let line = '';
|
||||
if (coordinates.length > 1) {
|
||||
const path = coordinates
|
||||
.map((coord, idx) => `${idx === 0 ? 'M' : 'L'}${coord.cx.toFixed(2)} ${coord.cy.toFixed(2)}`)
|
||||
.join(' ');
|
||||
line = `<path class="node-detail__chart-trend" d="${path}" fill="none" stroke="${hexToRgba(seriesConfig.color, 0.5)}" stroke-width="1.5" aria-hidden="true"></path>`;
|
||||
}
|
||||
return `${line}${circleEntries.join('')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a vertical axis when visible.
|
||||
* Resolve the effective axis maximum when upper overflow is allowed.
|
||||
*
|
||||
* @param {Object} axis Axis descriptor.
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @returns {string} SVG markup for the axis or an empty string.
|
||||
* @param {Array<{axisId: string, points: Array<{timestamp: number, value: number}>}>} seriesEntries Series entries.
|
||||
* @returns {number} Effective axis max.
|
||||
*/
|
||||
function renderYAxis(axis, dims) {
|
||||
if (!axis || axis.visible === false) {
|
||||
return '';
|
||||
function resolveAxisMax(axis, seriesEntries) {
|
||||
if (!axis || axis.allowUpperOverflow !== true) {
|
||||
return axis?.max;
|
||||
}
|
||||
const x = resolveAxisX(axis.position, dims);
|
||||
const ticks = axis.scale === 'log'
|
||||
? buildLogTicks(axis.min, axis.max)
|
||||
: buildLinearTicks(axis.min, axis.max, axis.ticks);
|
||||
const tickElements = ticks
|
||||
.map(value => {
|
||||
const y = scaleValueToAxis(value, axis, dims);
|
||||
const tickLength = axis.position === 'left' || axis.position === 'leftSecondary' ? -4 : 4;
|
||||
const textAnchor = axis.position === 'left' || axis.position === 'leftSecondary' ? 'end' : 'start';
|
||||
const textOffset = axis.position === 'left' || axis.position === 'leftSecondary' ? -6 : 6;
|
||||
return `
|
||||
<g class="node-detail__chart-tick" aria-hidden="true">
|
||||
<line x1="${x}" y1="${y.toFixed(2)}" x2="${(x + tickLength).toFixed(2)}" y2="${y.toFixed(2)}"></line>
|
||||
<text x="${(x + textOffset).toFixed(2)}" y="${(y + 3).toFixed(2)}" text-anchor="${textAnchor}" dominant-baseline="middle">${escapeHtml(formatAxisTick(value, axis))}</text>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
const labelPadding = axis.position === 'left' || axis.position === 'leftSecondary' ? -56 : 56;
|
||||
const labelX = x + labelPadding;
|
||||
const labelY = (dims.chartTop + dims.chartBottom) / 2;
|
||||
const labelTransform = `rotate(-90 ${labelX.toFixed(2)} ${labelY.toFixed(2)})`;
|
||||
return `
|
||||
<g class="node-detail__chart-axis node-detail__chart-axis--y" aria-hidden="true">
|
||||
<line x1="${x}" y1="${dims.chartTop}" x2="${x}" y2="${dims.chartBottom}"></line>
|
||||
${tickElements}
|
||||
<text class="node-detail__chart-axis-label" x="${labelX.toFixed(2)}" y="${labelY.toFixed(2)}" text-anchor="middle" dominant-baseline="middle" transform="${labelTransform}">${escapeHtml(axis.label)}</text>
|
||||
</g>
|
||||
`;
|
||||
let observedMax = null;
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || entry.axisId !== axis.id || !Array.isArray(entry.points)) continue;
|
||||
for (const point of entry.points) {
|
||||
if (!point || !Number.isFinite(point.value)) continue;
|
||||
observedMax = observedMax == null ? point.value : Math.max(observedMax, point.value);
|
||||
}
|
||||
}
|
||||
if (observedMax != null && Number.isFinite(axis.max) && observedMax > axis.max) {
|
||||
return observedMax;
|
||||
}
|
||||
return axis.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the horizontal floating seven-day axis with midnight ticks.
|
||||
*
|
||||
* @param {Object} dims Chart dimensions.
|
||||
* @param {number} domainStart Window start timestamp.
|
||||
* @param {number} domainEnd Window end timestamp.
|
||||
* @param {Array<number>} tickTimestamps Midnight tick timestamps.
|
||||
* @returns {string} SVG markup for the X axis.
|
||||
*/
|
||||
function renderXAxis(dims, domainStart, domainEnd, tickTimestamps, { labelFormatter = formatCompactDate } = {}) {
|
||||
const y = dims.chartBottom;
|
||||
const ticks = tickTimestamps
|
||||
.map(ts => {
|
||||
const x = scaleTimestamp(ts, domainStart, domainEnd, dims);
|
||||
const labelY = y + 18;
|
||||
const xStr = x.toFixed(2);
|
||||
const yStr = labelY.toFixed(2);
|
||||
const label = labelFormatter(ts);
|
||||
return `
|
||||
<g class="node-detail__chart-tick" aria-hidden="true">
|
||||
<line class="node-detail__chart-grid-line" x1="${xStr}" y1="${dims.chartTop}" x2="${xStr}" y2="${dims.chartBottom}"></line>
|
||||
<text x="${xStr}" y="${yStr}" text-anchor="end" dominant-baseline="central" transform="rotate(-90 ${xStr} ${yStr})">${escapeHtml(label)}</text>
|
||||
</g>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
return `
|
||||
<g class="node-detail__chart-axis node-detail__chart-axis--x" aria-hidden="true">
|
||||
<line x1="${dims.margin.left}" y1="${y}" x2="${dims.width - dims.margin.right}" y2="${y}"></line>
|
||||
${ticks}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single telemetry chart defined by ``spec``.
|
||||
* Build a telemetry chart model from a specification and series entries.
|
||||
*
|
||||
* @param {Object} spec Chart specification.
|
||||
* @param {Array<{timestamp: number, snapshot: Object}>} entries Telemetry entries.
|
||||
* @param {number} nowMs Reference timestamp.
|
||||
* @returns {string} Rendered chart markup or an empty string.
|
||||
* @param {Object} chartOptions Rendering overrides.
|
||||
* @returns {Object|null} Chart model or ``null`` when empty.
|
||||
*/
|
||||
function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
function buildTelemetryChartModel(spec, entries, nowMs, chartOptions = {}) {
|
||||
const windowMs = Number.isFinite(chartOptions.windowMs) && chartOptions.windowMs > 0 ? chartOptions.windowMs : TELEMETRY_WINDOW_MS;
|
||||
const timeRangeLabel = stringOrNull(chartOptions.timeRangeLabel) ?? 'Last 7 days';
|
||||
const domainEnd = nowMs;
|
||||
const domainStart = nowMs - windowMs;
|
||||
const dims = createChartDimensions(spec);
|
||||
const axisMap = new Map(spec.axes.map(axis => [axis.id, axis]));
|
||||
const seriesEntries = spec.series
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axis);
|
||||
if (!axis) return null;
|
||||
const points = buildSeriesPoints(entries, series.fields, domainStart, domainEnd);
|
||||
if (points.length === 0) return null;
|
||||
return { config: series, axis, points };
|
||||
return { config: series, axisId: series.axis, points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (seriesEntries.length === 0) {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
const adjustedAxes = spec.axes.map(axis => {
|
||||
const resolvedMax = resolveAxisMax(axis, seriesEntries);
|
||||
if (resolvedMax != null && resolvedMax !== axis.max) {
|
||||
return { ...axis, max: resolvedMax };
|
||||
}
|
||||
return axis;
|
||||
});
|
||||
const axisMap = new Map(adjustedAxes.map(axis => [axis.id, axis]));
|
||||
const plottedSeries = seriesEntries
|
||||
.map(series => {
|
||||
const axis = axisMap.get(series.axisId);
|
||||
if (!axis) return null;
|
||||
return { config: series.config, axis, points: series.points };
|
||||
})
|
||||
.filter(entry => entry != null);
|
||||
if (plottedSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const axesMarkup = spec.axes.map(axis => renderYAxis(axis, dims)).join('');
|
||||
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
|
||||
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
|
||||
const ticks = tickBuilder(nowMs, windowMs);
|
||||
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
|
||||
return {
|
||||
id: spec.id,
|
||||
title: spec.title,
|
||||
timeRangeLabel,
|
||||
domainStart,
|
||||
domainEnd,
|
||||
dims,
|
||||
axes: adjustedAxes,
|
||||
seriesEntries: plottedSeries,
|
||||
ticks: tickBuilder(nowMs, windowMs),
|
||||
tickFormatter,
|
||||
lineReducer: typeof chartOptions.lineReducer === 'function' ? chartOptions.lineReducer : null,
|
||||
};
|
||||
}
|
||||
|
||||
const seriesMarkup = seriesEntries
|
||||
.map(series =>
|
||||
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
|
||||
lineReducer: chartOptions.lineReducer,
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const legendItems = seriesEntries
|
||||
/**
|
||||
* Render a telemetry chart container for a chart model.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {string} Chart markup.
|
||||
*/
|
||||
function renderTelemetryChartMarkup(model) {
|
||||
const legendItems = model.seriesEntries
|
||||
.map(series => {
|
||||
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
|
||||
return `
|
||||
@@ -1173,22 +1062,428 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
const legendMarkup = legendItems
|
||||
? `<div class="node-detail__chart-legend" aria-hidden="true">${legendItems}</div>`
|
||||
: '';
|
||||
const ariaLabel = `${model.title} over last seven days`;
|
||||
return `
|
||||
<figure class="node-detail__chart">
|
||||
<figure class="node-detail__chart" data-telemetry-chart-id="${escapeHtml(model.id)}">
|
||||
<figcaption class="node-detail__chart-header">
|
||||
<h4>${escapeHtml(spec.title)}</h4>
|
||||
<span>${escapeHtml(timeRangeLabel)}</span>
|
||||
<h4>${escapeHtml(model.title)}</h4>
|
||||
<span>${escapeHtml(model.timeRangeLabel)}</span>
|
||||
</figcaption>
|
||||
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escapeHtml(`${spec.title} over last seven days`)}">
|
||||
${axesMarkup}
|
||||
${xAxisMarkup}
|
||||
${seriesMarkup}
|
||||
</svg>
|
||||
<div class="node-detail__chart-plot" data-telemetry-plot role="img" aria-label="${escapeHtml(ariaLabel)}"></div>
|
||||
${legendMarkup}
|
||||
</figure>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a sorted timestamp index shared across series entries.
|
||||
*
|
||||
* @param {Array<Object>} seriesEntries Plotted series entries.
|
||||
* @param {Function|null} lineReducer Optional line reducer.
|
||||
* @returns {{timestamps: Array<number>, indexByTimestamp: Map<number, number>}} Timestamp index.
|
||||
*/
|
||||
function buildChartTimestampIndex(seriesEntries, lineReducer) {
|
||||
const timestampSet = new Set();
|
||||
for (const entry of seriesEntries) {
|
||||
if (!entry || !Array.isArray(entry.points)) continue;
|
||||
entry.points.forEach(point => {
|
||||
if (point && Number.isFinite(point.timestamp)) {
|
||||
timestampSet.add(point.timestamp);
|
||||
}
|
||||
});
|
||||
if (typeof lineReducer === 'function') {
|
||||
const reduced = lineReducer(entry.points);
|
||||
if (Array.isArray(reduced)) {
|
||||
reduced.forEach(point => {
|
||||
if (point && Number.isFinite(point.timestamp)) {
|
||||
timestampSet.add(point.timestamp);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const indexByTimestamp = new Map(timestamps.map((ts, idx) => [ts, idx]));
|
||||
return { timestamps, indexByTimestamp };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of points into an aligned values array.
|
||||
*
|
||||
* @param {Array<{timestamp: number, value: number}>} points Series points.
|
||||
* @param {Map<number, number>} indexByTimestamp Timestamp index.
|
||||
* @param {number} length Length of the output array.
|
||||
* @returns {Array<number|null>} Values aligned to timestamps.
|
||||
*/
|
||||
function mapSeriesValues(points, indexByTimestamp, length) {
|
||||
const values = Array.from({ length }, () => null);
|
||||
if (!Array.isArray(points)) {
|
||||
return values;
|
||||
}
|
||||
for (const point of points) {
|
||||
if (!point || !Number.isFinite(point.timestamp)) continue;
|
||||
const idx = indexByTimestamp.get(point.timestamp);
|
||||
if (idx == null) continue;
|
||||
values[idx] = Number.isFinite(point.value) ? point.value : null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build uPlot series and data arrays for a chart model.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {{data: Array<Array<number|null>>, series: Array<Object>}} uPlot data and series config.
|
||||
*/
|
||||
function buildTelemetryChartData(model) {
|
||||
const { timestamps, indexByTimestamp } = buildChartTimestampIndex(model.seriesEntries, model.lineReducer);
|
||||
const data = [timestamps];
|
||||
const series = [{ label: 'Time' }];
|
||||
|
||||
model.seriesEntries.forEach(entry => {
|
||||
const baseConfig = {
|
||||
label: entry.config.label,
|
||||
scale: entry.axis.id,
|
||||
};
|
||||
if (model.lineReducer) {
|
||||
const reducedPoints = model.lineReducer(entry.points);
|
||||
const linePoints = Array.isArray(reducedPoints) && reducedPoints.length > 0 ? reducedPoints : entry.points;
|
||||
const lineValues = mapSeriesValues(linePoints, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: hexToRgba(entry.config.color, 0.5),
|
||||
width: 1.5,
|
||||
points: { show: false },
|
||||
});
|
||||
data.push(lineValues);
|
||||
|
||||
const pointValues = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: entry.config.color,
|
||||
width: 0,
|
||||
points: { show: true, size: 6, width: 1 },
|
||||
});
|
||||
data.push(pointValues);
|
||||
} else {
|
||||
const values = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
|
||||
series.push({
|
||||
...baseConfig,
|
||||
stroke: entry.config.color,
|
||||
width: 1.5,
|
||||
points: { show: true, size: 6, width: 1 },
|
||||
});
|
||||
data.push(values);
|
||||
}
|
||||
});
|
||||
|
||||
return { data, series };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build uPlot chart configuration and data for a telemetry chart.
|
||||
*
|
||||
* @param {Object} model Chart model.
|
||||
* @returns {{options: Object, data: Array<Array<number|null>>}} uPlot config and data.
|
||||
*/
|
||||
function buildUPlotChartConfig(model, { width, height, axisColor, gridColor } = {}) {
|
||||
const { data, series } = buildTelemetryChartData(model);
|
||||
const fallbackWidth = Math.round(model.dims.width * 1.8);
|
||||
const resolvedWidth = Number.isFinite(width) && width > 0 ? width : fallbackWidth;
|
||||
const resolvedHeight = Number.isFinite(height) && height > 0 ? height : model.dims.height;
|
||||
const axisStroke = stringOrNull(axisColor) ?? '#5c6773';
|
||||
const gridStroke = stringOrNull(gridColor) ?? 'rgba(12, 15, 18, 0.08)';
|
||||
const axes = [
|
||||
{
|
||||
scale: 'x',
|
||||
side: 2,
|
||||
stroke: axisStroke,
|
||||
grid: { show: true, stroke: gridStroke },
|
||||
splits: () => model.ticks,
|
||||
values: (u, splits) => splits.map(value => model.tickFormatter(value)),
|
||||
},
|
||||
];
|
||||
const scales = {
|
||||
x: {
|
||||
time: true,
|
||||
range: () => [model.domainStart, model.domainEnd],
|
||||
},
|
||||
};
|
||||
|
||||
model.axes.forEach(axis => {
|
||||
const ticks = axis.scale === 'log'
|
||||
? buildLogTicks(axis.min, axis.max)
|
||||
: buildLinearTicks(axis.min, axis.max, axis.ticks);
|
||||
const side = axis.position === 'right' || axis.position === 'rightSecondary' ? 1 : 3;
|
||||
axes.push({
|
||||
scale: axis.id,
|
||||
side,
|
||||
show: axis.visible !== false,
|
||||
stroke: axisStroke,
|
||||
grid: { show: false },
|
||||
label: axis.label,
|
||||
splits: () => ticks,
|
||||
values: (u, splits) => splits.map(value => formatAxisTick(value, axis)),
|
||||
});
|
||||
scales[axis.id] = {
|
||||
distr: axis.scale === 'log' ? 3 : 1,
|
||||
log: axis.scale === 'log' ? 10 : undefined,
|
||||
range: () => [axis.min, axis.max],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
options: {
|
||||
width: resolvedWidth,
|
||||
height: resolvedHeight,
|
||||
padding: [
|
||||
model.dims.margin.top,
|
||||
model.dims.margin.right,
|
||||
model.dims.margin.bottom,
|
||||
model.dims.margin.left,
|
||||
],
|
||||
legend: { show: false },
|
||||
series,
|
||||
axes,
|
||||
scales,
|
||||
},
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate uPlot charts for the provided chart models.
|
||||
*
|
||||
* @param {Array<Object>} chartModels Chart models to render.
|
||||
* @param {{root?: ParentNode, uPlotImpl?: Function}} [options] Rendering options.
|
||||
* @returns {Array<Object>} Instantiated uPlot charts.
|
||||
*/
|
||||
export function mountTelemetryCharts(chartModels, { root, uPlotImpl } = {}) {
|
||||
if (!Array.isArray(chartModels) || chartModels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const host = root ?? globalThis.document;
|
||||
if (!host || typeof host.querySelector !== 'function') {
|
||||
return [];
|
||||
}
|
||||
const uPlotCtor = typeof uPlotImpl === 'function' ? uPlotImpl : globalThis.uPlot;
|
||||
if (typeof uPlotCtor !== 'function') {
|
||||
console.warn('uPlot is unavailable; telemetry charts will not render.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const instances = [];
|
||||
const colorRoot = host?.ownerDocument?.body ?? host?.body ?? globalThis.document?.body ?? null;
|
||||
const axisColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
|
||||
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--muted').trim()
|
||||
: null;
|
||||
const gridColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
|
||||
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--line').trim()
|
||||
: null;
|
||||
chartModels.forEach(model => {
|
||||
const container = host.querySelector(`[data-telemetry-chart-id="${model.id}"]`);
|
||||
if (!container) return;
|
||||
const plotRoot = container.querySelector('[data-telemetry-plot]');
|
||||
if (!plotRoot) return;
|
||||
plotRoot.innerHTML = '';
|
||||
const plotWidth = plotRoot.clientWidth || plotRoot.getBoundingClientRect?.().width;
|
||||
const plotHeight = plotRoot.clientHeight || plotRoot.getBoundingClientRect?.().height;
|
||||
const { options, data } = buildUPlotChartConfig(model, {
|
||||
width: plotWidth ? Math.round(plotWidth) : undefined,
|
||||
height: plotHeight ? Math.round(plotHeight) : undefined,
|
||||
axisColor: axisColor || undefined,
|
||||
gridColor: gridColor || undefined,
|
||||
});
|
||||
const instance = new uPlotCtor(options, data, plotRoot);
|
||||
instance.__potatoMeshRoot = plotRoot;
|
||||
instances.push(instance);
|
||||
});
|
||||
registerTelemetryChartResize(instances);
|
||||
return instances;
|
||||
}
|
||||
|
||||
const telemetryResizeRegistry = new Set();
|
||||
const telemetryResizeObservers = new WeakMap();
|
||||
let telemetryResizeListenerAttached = false;
|
||||
let telemetryResizeDebounceId = null;
|
||||
const TELEMETRY_RESIZE_DEBOUNCE_MS = 120;
|
||||
|
||||
function resizeUPlotInstance(instance) {
|
||||
if (!instance || typeof instance.setSize !== 'function') {
|
||||
return;
|
||||
}
|
||||
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
|
||||
if (!root) return;
|
||||
const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;
|
||||
const width = Number.isFinite(root.clientWidth) ? root.clientWidth : rect?.width;
|
||||
const height = Number.isFinite(root.clientHeight) ? root.clientHeight : rect?.height;
|
||||
if (!width || !height) return;
|
||||
instance.setSize({ width: Math.round(width), height: Math.round(height) });
|
||||
}
|
||||
|
||||
function registerTelemetryChartResize(instances) {
|
||||
if (!Array.isArray(instances) || instances.length === 0) {
|
||||
return;
|
||||
}
|
||||
const scheduleResize = () => {
|
||||
if (telemetryResizeDebounceId != null) {
|
||||
clearTimeout(telemetryResizeDebounceId);
|
||||
}
|
||||
telemetryResizeDebounceId = setTimeout(() => {
|
||||
telemetryResizeDebounceId = null;
|
||||
telemetryResizeRegistry.forEach(instance => resizeUPlotInstance(instance));
|
||||
}, TELEMETRY_RESIZE_DEBOUNCE_MS);
|
||||
};
|
||||
instances.forEach(instance => {
|
||||
telemetryResizeRegistry.add(instance);
|
||||
resizeUPlotInstance(instance);
|
||||
if (typeof globalThis.ResizeObserver === 'function') {
|
||||
if (telemetryResizeObservers.has(instance)) return;
|
||||
const observer = new globalThis.ResizeObserver(scheduleResize);
|
||||
telemetryResizeObservers.set(instance, observer);
|
||||
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
|
||||
if (root && typeof observer.observe === 'function') {
|
||||
observer.observe(root);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!telemetryResizeListenerAttached && typeof globalThis.addEventListener === 'function') {
|
||||
globalThis.addEventListener('resize', () => {
|
||||
scheduleResize();
|
||||
});
|
||||
telemetryResizeListenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLoadUPlot({ documentRef, onLoad }) {
|
||||
if (!documentRef || typeof documentRef.querySelector !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const existing = documentRef.querySelector('script[data-uplot-loader="true"]');
|
||||
if (existing) {
|
||||
if (existing.dataset.loaded === 'true' && typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
} else if (typeof existing.addEventListener === 'function' && typeof onLoad === 'function') {
|
||||
existing.addEventListener('load', onLoad, { once: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (typeof documentRef.createElement !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const script = documentRef.createElement('script');
|
||||
script.src = '/assets/vendor/uplot/uPlot.iife.min.js';
|
||||
script.defer = true;
|
||||
script.dataset.uplotLoader = 'true';
|
||||
if (typeof script.addEventListener === 'function') {
|
||||
script.addEventListener('load', () => {
|
||||
script.dataset.loaded = 'true';
|
||||
if (typeof onLoad === 'function') {
|
||||
onLoad();
|
||||
}
|
||||
});
|
||||
}
|
||||
const head = documentRef.head ?? documentRef.body;
|
||||
if (head && typeof head.appendChild === 'function') {
|
||||
head.appendChild(script);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount telemetry charts, retrying briefly if uPlot has not loaded yet.
|
||||
*
|
||||
* @param {Array<Object>} chartModels Chart models to render.
|
||||
* @param {{root?: ParentNode, uPlotImpl?: Function, loadUPlot?: Function}} [options] Rendering options.
|
||||
* @returns {Array<Object>} Instantiated uPlot charts.
|
||||
*/
|
||||
export function mountTelemetryChartsWithRetry(chartModels, { root, uPlotImpl, loadUPlot } = {}) {
|
||||
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (instances.length > 0 || typeof uPlotImpl === 'function') {
|
||||
return instances;
|
||||
}
|
||||
const host = root ?? globalThis.document;
|
||||
if (!host || typeof host.querySelector !== 'function') {
|
||||
return instances;
|
||||
}
|
||||
let mounted = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
const retryDelayMs = 50;
|
||||
const retry = () => {
|
||||
if (mounted) return;
|
||||
attempts += 1;
|
||||
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (next.length > 0) {
|
||||
mounted = true;
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
return;
|
||||
}
|
||||
setTimeout(retry, retryDelayMs);
|
||||
};
|
||||
const loadFn = typeof loadUPlot === 'function' ? loadUPlot : defaultLoadUPlot;
|
||||
loadFn({
|
||||
documentRef: host.ownerDocument ?? globalThis.document,
|
||||
onLoad: () => {
|
||||
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
|
||||
if (next.length > 0) {
|
||||
mounted = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
setTimeout(retry, 0);
|
||||
return instances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create chart markup and models for telemetry charts.
|
||||
*
|
||||
* @param {Object} node Normalised node payload.
|
||||
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
|
||||
* @returns {{chartsHtml: string, chartModels: Array<Object>}} Chart markup and models.
|
||||
*/
|
||||
export function createTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
|
||||
const telemetrySource = node?.rawSources?.telemetry;
|
||||
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
|
||||
? node.rawSources.telemetrySnapshots
|
||||
: null;
|
||||
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
|
||||
? telemetrySource.snapshots
|
||||
: null;
|
||||
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
|
||||
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const entries = rawSnapshots
|
||||
.map(snapshot => {
|
||||
const timestamp = resolveSnapshotTimestamp(snapshot);
|
||||
if (timestamp == null) return null;
|
||||
return { timestamp, snapshot };
|
||||
})
|
||||
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (entries.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const chartModels = TELEMETRY_CHART_SPECS
|
||||
.map(spec => buildTelemetryChartModel(spec, entries, nowMs, chartOptions))
|
||||
.filter(model => model != null);
|
||||
if (chartModels.length === 0) {
|
||||
return { chartsHtml: '', chartModels: [] };
|
||||
}
|
||||
const chartsHtml = `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${chartModels.map(model => renderTelemetryChartMarkup(model)).join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
return { chartsHtml, chartModels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the telemetry charts for the supplied node when telemetry snapshots
|
||||
* exist.
|
||||
@@ -1198,41 +1493,7 @@ function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
|
||||
* @returns {string} Chart grid markup or an empty string.
|
||||
*/
|
||||
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
|
||||
const telemetrySource = node?.rawSources?.telemetry;
|
||||
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
|
||||
? node.rawSources.telemetrySnapshots
|
||||
: null;
|
||||
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
|
||||
? telemetrySource.snapshots
|
||||
: null;
|
||||
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
|
||||
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const entries = rawSnapshots
|
||||
.map(snapshot => {
|
||||
const timestamp = resolveSnapshotTimestamp(snapshot);
|
||||
if (timestamp == null) return null;
|
||||
return { timestamp, snapshot };
|
||||
})
|
||||
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
if (entries.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const charts = TELEMETRY_CHART_SPECS
|
||||
.map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions))
|
||||
.filter(chart => stringOrNull(chart));
|
||||
if (charts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return `
|
||||
<section class="node-detail__charts">
|
||||
<div class="node-detail__charts-grid">
|
||||
${charts.join('')}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
return createTelemetryCharts(node, { nowMs, chartOptions }).chartsHtml;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2254,6 +2515,7 @@ function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = n
|
||||
* messages?: Array<Object>,
|
||||
* traces?: Array<Object>,
|
||||
* renderShortHtml: Function,
|
||||
* chartsHtml?: string,
|
||||
* }} options Rendering options.
|
||||
* @returns {string} HTML fragment representing the detail view.
|
||||
*/
|
||||
@@ -2263,6 +2525,7 @@ function renderNodeDetailHtml(node, {
|
||||
traces = [],
|
||||
renderShortHtml,
|
||||
roleIndex = null,
|
||||
chartsHtml = null,
|
||||
chartNowMs = Date.now(),
|
||||
} = {}) {
|
||||
const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, {
|
||||
@@ -2276,7 +2539,7 @@ function renderNodeDetailHtml(node, {
|
||||
const longName = stringOrNull(node.longName ?? node.long_name);
|
||||
const identifier = stringOrNull(node.nodeId ?? node.node_id);
|
||||
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
|
||||
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
|
||||
const telemetryChartsHtml = stringOrNull(chartsHtml) ?? renderTelemetryCharts(node, { nowMs: chartNowMs });
|
||||
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
|
||||
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
|
||||
const messagesHtml = renderMessages(messages, renderShortHtml, node);
|
||||
@@ -2302,7 +2565,7 @@ function renderNodeDetailHtml(node, {
|
||||
<header class="node-detail__header">
|
||||
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
|
||||
</header>
|
||||
${chartsHtml ?? ''}
|
||||
${telemetryChartsHtml ?? ''}
|
||||
${tableSection}
|
||||
${contentHtml}
|
||||
`;
|
||||
@@ -2416,15 +2679,17 @@ async function fetchTracesForNode(identifier, { fetchImpl } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the node detail page by hydrating the DOM with fetched data.
|
||||
* Fetch node detail data and render the HTML fragment.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* chartNowMs?: number,
|
||||
* chartOptions?: Object,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
|
||||
* @returns {Promise<string|{html: string, chartModels: Array<Object>}>} Rendered markup or chart models when requested.
|
||||
*/
|
||||
export async function fetchNodeDetailHtml(referenceData, options = {}) {
|
||||
if (!referenceData || typeof referenceData !== 'object') {
|
||||
@@ -2454,15 +2719,38 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) {
|
||||
fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }),
|
||||
]);
|
||||
const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl });
|
||||
return renderNodeDetailHtml(node, {
|
||||
const chartNowMs = Number.isFinite(options.chartNowMs) ? options.chartNowMs : Date.now();
|
||||
const chartState = createTelemetryCharts(node, {
|
||||
nowMs: chartNowMs,
|
||||
chartOptions: options.chartOptions ?? {},
|
||||
});
|
||||
const html = renderNodeDetailHtml(node, {
|
||||
neighbors: node.neighbors,
|
||||
messages,
|
||||
traces,
|
||||
renderShortHtml,
|
||||
roleIndex,
|
||||
chartsHtml: chartState.chartsHtml,
|
||||
chartNowMs,
|
||||
});
|
||||
if (options.returnState === true) {
|
||||
return { html, chartModels: chartState.chartModels };
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise the standalone node detail page and mount telemetry charts.
|
||||
*
|
||||
* @param {{
|
||||
* document?: Document,
|
||||
* fetchImpl?: Function,
|
||||
* refreshImpl?: Function,
|
||||
* renderShortHtml?: Function,
|
||||
* uPlotImpl?: Function,
|
||||
* }} options Optional overrides for testing.
|
||||
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
|
||||
*/
|
||||
export async function initializeNodeDetailPage(options = {}) {
|
||||
const documentRef = options.document ?? globalThis.document;
|
||||
if (!documentRef || typeof documentRef.querySelector !== 'function') {
|
||||
@@ -2499,13 +2787,15 @@ export async function initializeNodeDetailPage(options = {}) {
|
||||
const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true';
|
||||
|
||||
try {
|
||||
const html = await fetchNodeDetailHtml(referenceData, {
|
||||
const result = await fetchNodeDetailHtml(referenceData, {
|
||||
fetchImpl: options.fetchImpl,
|
||||
refreshImpl,
|
||||
renderShortHtml: options.renderShortHtml,
|
||||
privateMode,
|
||||
returnState: true,
|
||||
});
|
||||
root.innerHTML = html;
|
||||
root.innerHTML = result.html;
|
||||
mountTelemetryChartsWithRetry(result.chartModels, { root, uPlotImpl: options.uPlotImpl });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to render node detail page', error);
|
||||
@@ -2542,7 +2832,11 @@ export const __testUtils = {
|
||||
categoriseNeighbors,
|
||||
renderNeighborGroups,
|
||||
renderSingleNodeTable,
|
||||
createTelemetryCharts,
|
||||
renderTelemetryCharts,
|
||||
mountTelemetryCharts,
|
||||
mountTelemetryChartsWithRetry,
|
||||
buildUPlotChartConfig,
|
||||
renderMessages,
|
||||
renderTraceroutes,
|
||||
renderTracePath,
|
||||
|
||||
@@ -994,7 +994,7 @@ body.dark .node-detail-overlay__close:hover {
|
||||
.node-detail__charts-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 1152px), 1fr));
|
||||
}
|
||||
|
||||
.node-detail__chart {
|
||||
@@ -1026,10 +1026,45 @@ body.dark .node-detail-overlay__close:hover {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.node-detail__chart svg {
|
||||
.node-detail__chart-plot {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: clamp(240px, 50vw, 360px);
|
||||
max-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .uplot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .uplot .u-wrap,
|
||||
.node-detail__chart-plot .uplot .u-under,
|
||||
.node-detail__chart-plot .uplot .u-over {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .u-axis,
|
||||
.node-detail__chart-plot .u-axis .u-label,
|
||||
.node-detail__chart-plot .u-axis .u-value,
|
||||
.node-detail__chart-plot .u-axis text,
|
||||
.node-detail__chart-plot .u-axis-label {
|
||||
color: var(--muted) !important;
|
||||
fill: var(--muted) !important;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.node-detail__chart-plot .u-grid {
|
||||
stroke: rgba(12, 15, 18, 0.08);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
body.dark .node-detail__chart-plot .u-grid {
|
||||
stroke: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.node-detail__chart-axis line {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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 { mkdir, copyFile, access } from 'node:fs/promises';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Resolve an absolute path relative to this script location.
|
||||
*
|
||||
* @param {string[]} segments Path segments to append.
|
||||
* @returns {string} Absolute path resolved from this script.
|
||||
*/
|
||||
function resolvePath(...segments) {
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
return path.resolve(scriptDir, ...segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the uPlot assets are available within the public asset tree.
|
||||
*
|
||||
* @returns {Promise<void>} Resolves once files have been copied.
|
||||
*/
|
||||
async function copyUPlotAssets() {
|
||||
const sourceDir = resolvePath('..', 'node_modules', 'uplot', 'dist');
|
||||
const targetDir = resolvePath('..', 'public', 'assets', 'vendor', 'uplot');
|
||||
const assets = ['uPlot.iife.min.js', 'uPlot.min.css'];
|
||||
|
||||
await access(sourceDir, fsConstants.R_OK);
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
await Promise.all(
|
||||
assets.map(async asset => {
|
||||
const source = path.join(sourceDir, asset);
|
||||
const target = path.join(targetDir, asset);
|
||||
await copyFile(source, target);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await copyUPlotAssets();
|
||||
+264
-3
@@ -1080,7 +1080,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets.first).to eq("potatomesh.net")
|
||||
seed_domains = PotatoMesh::Config.federation_seed_domains.map(&:downcase)
|
||||
expect(targets.first(seed_domains.length)).to eq(seed_domains)
|
||||
expect(targets).to include("remote.mesh")
|
||||
expect(targets).not_to include("self.mesh")
|
||||
end
|
||||
@@ -1090,7 +1091,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
end
|
||||
|
||||
it "ignores remote instances that have not updated within a week" do
|
||||
@@ -1118,7 +1119,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
expect(targets).to eq(PotatoMesh::Config.federation_seed_domains.map(&:downcase))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3016,6 +3017,69 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
|
||||
it "ignores broadcast identifiers when creating placeholders" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_002,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!ffffffff",
|
||||
"channel" => 0,
|
||||
"text" => "broadcast",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM nodes WHERE node_id = '!ffffffff'")
|
||||
expect(count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it "creates hidden nodes for unseen message participants" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
|
||||
payload = {
|
||||
"id" => 10_001,
|
||||
"rx_time" => reference_time.to_i,
|
||||
"from_id" => "!cafef00d",
|
||||
"to_id" => "!deadbeef",
|
||||
"channel" => 0,
|
||||
"portnum" => "TEXT_MESSAGE_APP",
|
||||
"text" => "Spec participant placeholder",
|
||||
}
|
||||
|
||||
post "/api/messages", payload.to_json, auth_headers
|
||||
|
||||
expect(last_response).to be_ok
|
||||
expect(JSON.parse(last_response.body)).to eq("status" => "ok")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
rows = db.execute(
|
||||
<<~SQL,
|
||||
SELECT node_id, num, short_name, long_name, role, last_heard, first_heard
|
||||
FROM nodes
|
||||
WHERE node_id IN ("!cafef00d", "!deadbeef")
|
||||
ORDER BY node_id
|
||||
SQL
|
||||
)
|
||||
|
||||
expect(rows.map { |row| row["node_id"] }).to contain_exactly("!cafef00d", "!deadbeef")
|
||||
rows.each do |row|
|
||||
expect(row["num"]).to be_an(Integer)
|
||||
expect(row["role"]).to eq("CLIENT_HIDDEN")
|
||||
expect(row["short_name"]).to eq(row["node_id"][-4, 4].upcase)
|
||||
expect(row["long_name"]).to eq("Meshtastic #{row["short_name"]}")
|
||||
expect(row["last_heard"]).to eq(reference_time.to_i)
|
||||
expect(row["first_heard"]).to eq(reference_time.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "returns 400 when the payload is not valid JSON" do
|
||||
post "/api/messages", "{", auth_headers
|
||||
|
||||
@@ -4106,6 +4170,39 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(payload["node_id"]).to eq("!fresh-node")
|
||||
end
|
||||
|
||||
it "filters node results using the since parameter for collections and single lookups" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_last_heard = now - 120
|
||||
recent_last_heard = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!older-node", "old", "Older", "TBEAM", "CLIENT", 0.0, older_last_heard, older_last_heard],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!recent-node", "new", "Recent", "TBEAM", "CLIENT", 0.0, recent_last_heard, recent_last_heard],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/nodes?since=#{recent_last_heard}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["node_id"] }).to eq(["!recent-node"])
|
||||
|
||||
get "/api/nodes/!older-node?since=#{recent_last_heard}"
|
||||
expect(last_response.status).to eq(404)
|
||||
|
||||
get "/api/nodes/!recent-node?since=#{recent_last_heard}"
|
||||
expect(last_response).to be_ok
|
||||
detail = JSON.parse(last_response.body)
|
||||
expect(detail["node_id"]).to eq("!recent-node")
|
||||
end
|
||||
|
||||
it "omits blank values from node responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4467,6 +4564,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "filters positions using the since parameter for both global and node queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 180
|
||||
recent_rx = now - 15
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!pos-since", 101, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 52.0, 13.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO positions(id, node_id, node_num, rx_time, rx_iso, position_time, latitude, longitude) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!pos-since", 101, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 53.0, 14.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/positions?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/positions/!pos-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
end
|
||||
|
||||
it "omits blank values from position responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4565,6 +4693,49 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.first["rx_time"]).to eq(fresh_rx)
|
||||
end
|
||||
|
||||
it "honours the since parameter for neighbor queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 30
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!origin-since", "orig", "Origin", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-old", "oldn", "Neighbor Old", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)",
|
||||
["!neighbor-new", "newn", "Neighbor New", "TBEAM", "CLIENT", 0.0, now, now],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-old", 1.5, older_rx],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES(?,?,?,?)",
|
||||
["!origin-since", "!neighbor-new", 7.5, recent_rx],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/neighbors?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
|
||||
get "/api/neighbors/!origin-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new"])
|
||||
end
|
||||
|
||||
it "omits blank values from neighbor responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4695,6 +4866,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(filtered.map { |row| row["id"] }).to eq([2])
|
||||
end
|
||||
|
||||
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 60
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[10, "!tele-since", 21, older_rx, Time.at(older_rx).utc.iso8601, older_rx - 5, 20.0, 3.9],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, node_num, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?,?)",
|
||||
[11, "!tele-since", 21, recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 5, 80.0, 4.1],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([11])
|
||||
|
||||
get "/api/telemetry/!tele-since?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
filtered = JSON.parse(last_response.body)
|
||||
expect(filtered.map { |row| row["id"] }).to eq([11])
|
||||
end
|
||||
|
||||
it "omits blank values from telemetry responses" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -4858,6 +5060,34 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(buckets.first["bucket_seconds"]).to eq(PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS)
|
||||
end
|
||||
|
||||
it "filters aggregated telemetry buckets using the since parameter" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 1800
|
||||
recent_rx = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[801, "!agg-since", older_rx, Time.at(older_rx).utc.iso8601, older_rx - 30, 30.0],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level) VALUES(?,?,?,?,?,?)",
|
||||
[802, "!agg-since", recent_rx, Time.at(recent_rx).utc.iso8601, recent_rx - 30, 80.0],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300&since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
buckets = JSON.parse(last_response.body)
|
||||
expect(buckets.length).to eq(1)
|
||||
aggregates = buckets.first.fetch("aggregates")
|
||||
expect(aggregates).to have_key("battery_level")
|
||||
expect_same_value(aggregates.dig("battery_level", "avg"), 80.0)
|
||||
end
|
||||
|
||||
it "omits zero-valued battery and voltage aggregates" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
@@ -5025,6 +5255,37 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
ids = JSON.parse(last_response.body).map { |row| row["id"] }
|
||||
expect(ids).to eq([50_001])
|
||||
end
|
||||
|
||||
it "filters traces using the since parameter for collection and scoped requests" do
|
||||
clear_database
|
||||
allow(Time).to receive(:now).and_return(reference_time)
|
||||
now = reference_time.to_i
|
||||
older_rx = now - 300
|
||||
recent_rx = now - 25
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_001, 123, 456, older_rx, Time.at(older_rx).utc.iso8601],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO traces(id, src, dest, rx_time, rx_iso) VALUES(?,?,?,?,?)",
|
||||
[60_002, 123, 456, recent_rx, Time.at(recent_rx).utc.iso8601],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/traces?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |row| row["id"] }).to eq([60_002])
|
||||
|
||||
get "/api/traces/123?since=#{recent_rx}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
scoped = JSON.parse(last_response.body)
|
||||
expect(scoped.map { |row| row["id"] }).to eq([60_002])
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /nodes/:id" do
|
||||
|
||||
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
node_id: "!abc12345",
|
||||
start_time: now - 120,
|
||||
last_seen_time: now - 60,
|
||||
version: "0.5.8",
|
||||
version: "0.5.9",
|
||||
lora_freq: 915,
|
||||
modem_preset: "LongFast",
|
||||
}.merge(overrides)
|
||||
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!fresh000", now - 100, now - 10, "0.5.8"],
|
||||
["!fresh000", now - 100, now - 10, "0.5.9"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
@@ -141,7 +141,7 @@ RSpec.describe "Ingestor endpoints" do
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version, lora_freq, modem_preset) VALUES(?,?,?,?,?,?)",
|
||||
["!rich000", now - 200, now - 100, "0.5.8", 915, "MediumFast"],
|
||||
["!rich000", now - 200, now - 100, "0.5.9", 915, "MediumFast"],
|
||||
)
|
||||
end
|
||||
|
||||
@@ -159,6 +159,30 @@ RSpec.describe "Ingestor endpoints" do
|
||||
expect(rich["start_time_iso"]).to be_a(String)
|
||||
expect(rich["last_seen_iso"]).to be_a(String)
|
||||
end
|
||||
|
||||
it "filters ingestors using the since parameter" do
|
||||
frozen_time = Time.at(1_700_000_000)
|
||||
allow(Time).to receive(:now).and_return(frozen_time)
|
||||
now = frozen_time.to_i
|
||||
recent_cutoff = now - 120
|
||||
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!old-ingestor", now - 600, now - 300, "0.5.5"],
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
|
||||
["!new-ingestor", now - 60, now - 30, "0.5.9"],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/ingestors?since=#{recent_cutoff}"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
expect(payload.map { |entry| entry["node_id"] }).to eq(["!new-ingestor"])
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema migrations" do
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
|
||||
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
|
||||
<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">
|
||||
<div id="chartsPage" class="charts-page__content" data-telemetry-root="true">
|
||||
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
<% unless private_mode %>
|
||||
<%= erb :"shared/_chat_panel", locals: { full_screen: false } %>
|
||||
<% end %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: false, legend_collapsed: true } %>
|
||||
</div>
|
||||
|
||||
<%= erb :"shared/_nodes_table", locals: { full_screen: false } %>
|
||||
|
||||
+1
-1
@@ -14,5 +14,5 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<section class="full-screen-section full-screen-section--map">
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
|
||||
<%= erb :"shared/_map_panel", locals: { full_screen: true, legend_collapsed: true } %>
|
||||
</section>
|
||||
|
||||
@@ -17,11 +17,14 @@
|
||||
short_display = node_page_short_name || "Loading"
|
||||
long_display = node_page_long_name
|
||||
identifier_display = node_page_identifier || "" %>
|
||||
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
|
||||
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
|
||||
<section
|
||||
id="nodeDetail"
|
||||
class="node-detail"
|
||||
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
|
||||
data-private-mode="<%= private_mode ? "true" : "false" %>"
|
||||
data-telemetry-root="true"
|
||||
>
|
||||
<header class="node-detail__header">
|
||||
<h2 class="node-detail__title">
|
||||
|
||||
@@ -14,8 +14,12 @@
|
||||
limitations under the License.
|
||||
-->
|
||||
<% map_classes = ["map-panel"]
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel">
|
||||
map_classes << "map-panel--full" if defined?(full_screen) && full_screen
|
||||
data_attrs = []
|
||||
if defined?(legend_collapsed)
|
||||
data_attrs << "data-legend-collapsed=\\" #{legend_collapsed ? 'true' : 'false'}\\""
|
||||
end %>
|
||||
<div class="<%= map_classes.join(" ") %>" id="mapPanel" <%= data_attrs.join(" ") %>>
|
||||
<div id="map" role="region" aria-label="Nodes map"></div>
|
||||
<div class="map-toolbar" role="group" aria-label="Map view controls">
|
||||
<button id="mapFullscreenToggle" type="button" aria-pressed="false" aria-label="Enter full screen map view">
|
||||
|
||||
Reference in New Issue
Block a user