Compare commits

...

12 Commits

Author SHA1 Message Date
l5y baf6ffff0b web: improve instances map and table view (#546)
* web: improve instances map and table view

* web: address review comments

* run rufo
2025-12-14 14:35:55 +01:00
l5y 135de0863c web: fix traces submission with optional fields on udp (#545) 2025-12-14 13:27:07 +01:00
l5y 074a61baac chore: bump version to 0.5.7 (#542)
* chore: bump version to 0.5.7

* Change version to 0.5.7 in AppFrameworkInfo.plist

Updated version numbers to 0.5.7.
2025-12-08 20:39:58 +01:00
l5y 209cc948bf Handle zero telemetry aggregates (#538)
* Handle zero telemetry aggregates

* Fix telemetry aggregation to drop zero readings
2025-12-08 20:31:32 +01:00
l5y cc108f2f49 web: fix telemetry api to return current in amperes (#541)
* web: fix telemetry api to return current in amperes

* web: address review comments
2025-12-08 20:18:10 +01:00
l5y 844204f64d web: fix traces rendering (#535)
* web: fix traces rendering

* web: remove icon shortcuts

* web: further refine the trace routes
2025-12-08 19:48:33 +01:00
l5y 88f699f4ec Normalize numeric roles in node snapshots (#539) 2025-12-08 19:47:50 +01:00
l5y d1b9196f47 Use INSTANCE_DOMAIN env for ingestor (#536)
* Use INSTANCE_DOMAIN env for ingestor

* Normalize instance domain handling
2025-12-07 11:05:13 +01:00
l5y 8181fc8e03 web: further refine the federation page (#534)
* web: further refine the federation page

* web: address review comments

* web: address review comments
2025-12-04 13:31:23 +01:00
apo-mak 5be2ac417a Add Federation Map (#532)
* Add Federation Map

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: l5y <220195275+l5yth@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 12:24:54 +01:00
apo-mak 6acb1c833c add contact link to the instance data (#533) 2025-12-04 12:17:26 +01:00
l5y 2bd69415c1 matrix: create potato-matrix-bridge (#528)
* matrix: create potato-matrix-bridge

* matrix: add unit tests

* matrix: address review comments

* ci: condition github actions to only run on paths affected...

* Add comprehensive unit tests for config, matrix, potatomesh, and main modules

* Revert "Add comprehensive unit tests for config, matrix, potatomesh, and main modules"

This reverts commit 212522b4a2.

* matrix: add unit tests

* matrix: add unit tests

* matrix: add unit tests
2025-11-29 08:52:20 +01:00
55 changed files with 4344 additions and 74 deletions
+2
View File
@@ -36,6 +36,8 @@ jobs:
include:
- language: python
build-mode: none
- language: rust
build-mode: none
- language: ruby
build-mode: none
- language: javascript-typescript
+4
View File
@@ -19,6 +19,9 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths:
- 'web/**'
- 'tests/**'
permissions:
contents: read
@@ -47,6 +50,7 @@ jobs:
files: web/reports/javascript-coverage.json
flags: frontend
name: frontend
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test results to Codecov
+4
View File
@@ -19,6 +19,9 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths:
- 'app/**'
- 'tests/**'
permissions:
contents: read
@@ -63,5 +66,6 @@ jobs:
files: coverage/lcov.info
flags: flutter-mobile
name: flutter-mobile
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+4
View File
@@ -19,6 +19,9 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths:
- 'data/**'
- 'tests/**'
permissions:
contents: read
@@ -47,6 +50,7 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
files: reports/python-coverage.xml
flags: python-ingestor
fail_ci_if_error: false
name: python-ingestor
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+3
View File
@@ -19,6 +19,9 @@ on:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths:
- 'web/**'
- 'tests/**'
permissions:
contents: read
+78
View File
@@ -0,0 +1,78 @@
# 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: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'matrix/**'
- 'tests/**'
permissions:
contents: read
jobs:
matrix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
./matrix/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml', '**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Show rustc version
run: rustc --version
- name: Install llvm-tools-preview component
run: rustup component add llvm-tools-preview --toolchain stable
- name: Install cargo-llvm-cov
working-directory: ./matrix
run: cargo install cargo-llvm-cov --locked
- name: Check formatting
working-directory: ./matrix
run: cargo fmt --all -- --check
- name: Clippy lint
working-directory: ./matrix
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build
working-directory: ./matrix
run: cargo build --all --all-features
- name: Test
working-directory: ./matrix
run: cargo test --all --all-features --verbose
- name: Run tests with coverage
working-directory: ./matrix
run: |
cargo llvm-cov --all-features --workspace --lcov --output-path coverage.lcov
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./matrix/coverage.lcov
flags: matrix-bridge
name: matrix-bridge
fail_ci_if_error: false
+4 -3
View File
@@ -57,9 +57,10 @@ Additional environment variables are optional:
| `PRIVATE` | `0` | Restricts public visibility and disables chat/message endpoints when set to `1`. |
| `CONNECTION` | `/dev/ttyACM0` | Serial device, TCP endpoint, or Bluetooth target used by the ingestor to reach the radio. |
The ingestor also respects supporting variables such as `POTATOMESH_INSTANCE`
(defaults to `http://web:41447`) for remote posting and `CHANNEL_INDEX` when
selecting a LoRa channel on serial or Bluetooth connections.
The ingestor posts to the URL configured via `INSTANCE_DOMAIN` (defaulting to
`http://web:41447` in the provided compose file) and still accepts
`POTATOMESH_INSTANCE` as a legacy alias when the primary variable is unset. Use
`CHANNEL_INDEX` to select a LoRa channel on serial or Bluetooth connections.
## Docker Compose file
+5 -3
View File
@@ -176,7 +176,7 @@ to the configured potato-mesh instance.
Check out `mesh.sh` ingestor script in the `./data` directory.
```bash
POTATOMESH_INSTANCE=http://127.0.0.1:41447 API_TOKEN=1eb140fd-cab4-40be-b862-41c607762246 CONNECTION=/dev/ttyACM0 DEBUG=1 ./mesh.sh
INSTANCE_DOMAIN=http://127.0.0.1:41447 API_TOKEN=1eb140fd-cab4-40be-b862-41c607762246 CONNECTION=/dev/ttyACM0 DEBUG=1 ./mesh.sh
[2025-02-20T12:34:56.789012Z] [potato-mesh] [info] channel=0 context=daemon.main port='41447' target='http://127.0.0.1' Mesh daemon starting
[...]
[2025-02-20T12:34:57.012345Z] [potato-mesh] [debug] context=handlers.upsert_node node_id=!849b7154 short_name='7154' long_name='7154' Queued node upsert payload
@@ -184,12 +184,14 @@ POTATOMESH_INSTANCE=http://127.0.0.1:41447 API_TOKEN=1eb140fd-cab4-40be-b862-41c
[2025-02-20T12:34:58.001122Z] [potato-mesh] [debug] context=handlers.store_packet_dict channel=0 from_id='!9ee71c38' payload='Guten Morgen!' to_id='^all' Queued message payload
```
Run the script with `POTATOMESH_INSTANCE` and `API_TOKEN` to keep updating
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.
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. The
ingestor will still honor the legacy `POTATOMESH_INSTANCE` variable when
`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments.
## Docker
+2 -2
View File
@@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>0.5.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>0.5.7</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>
+1 -1
View File
@@ -1,7 +1,7 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.5.6
version: 0.5.7
environment:
sdk: ">=3.4.0 <4.0.0"
+2 -2
View File
@@ -50,7 +50,7 @@ USER potatomesh
ENV CONNECTION=/dev/ttyACM0 \
CHANNEL_INDEX=0 \
DEBUG=0 \
POTATOMESH_INSTANCE="" \
INSTANCE_DOMAIN="" \
API_TOKEN=""
CMD ["python", "-m", "data.mesh"]
@@ -75,7 +75,7 @@ USER ContainerUser
ENV CONNECTION=/dev/ttyACM0 \
CHANNEL_INDEX=0 \
DEBUG=0 \
POTATOMESH_INSTANCE="" \
INSTANCE_DOMAIN="" \
API_TOKEN=""
CMD ["python", "-m", "data.mesh"]
+1 -1
View File
@@ -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.6"
VERSION = "0.5.7"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+2
View File
@@ -26,6 +26,8 @@ CREATE TABLE IF NOT EXISTS instances (
longitude REAL,
last_update_time INTEGER,
is_private BOOLEAN NOT NULL DEFAULT 0,
nodes_count INTEGER,
contact_link TEXT,
signature TEXT
);
+23 -1
View File
@@ -61,7 +61,29 @@ CHANNEL_INDEX = int(os.environ.get("CHANNEL_INDEX", str(DEFAULT_CHANNEL_INDEX)))
"""Index of the LoRa channel to select when connecting."""
DEBUG = os.environ.get("DEBUG") == "1"
INSTANCE = os.environ.get("POTATOMESH_INSTANCE", "").rstrip("/")
def _resolve_instance_domain() -> str:
"""Resolve the configured instance domain from the environment.
The ingestor prefers the :envvar:`INSTANCE_DOMAIN` variable for clarity and
compatibility with the web application. For deployments that still
configure the legacy :envvar:`POTATOMESH_INSTANCE` variable, the resolver
falls back to that value when no primary domain is set.
"""
instance_domain = os.environ.get("INSTANCE_DOMAIN", "")
legacy_instance = os.environ.get("POTATOMESH_INSTANCE", "")
configured_instance = (instance_domain or legacy_instance).rstrip("/")
if configured_instance and "://" not in configured_instance:
return f"https://{configured_instance}"
return configured_instance
INSTANCE = _resolve_instance_domain()
API_TOKEN = os.environ.get("API_TOKEN", "")
ENERGY_SAVING = os.environ.get("ENERGY_SAVING") == "1"
"""When ``True``, enables the ingestor's energy saving mode."""
+1 -1
View File
@@ -260,7 +260,7 @@ def main(existing_interface=None) -> None:
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
target = config.INSTANCE or "(no POTATOMESH_INSTANCE)"
target = config.INSTANCE or "(no INSTANCE_DOMAIN configured)"
configured_port = config.CONNECTION
active_candidate = configured_port
announced_target = False
+1 -1
View File
@@ -49,9 +49,9 @@ x-ingestor-base: &ingestor-base
environment:
CONNECTION: ${CONNECTION:-/dev/ttyACM0}
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
POTATOMESH_INSTANCE: ${POTATOMESH_INSTANCE:-http://web:41447}
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
POTATOMESH_INSTANCE: ${POTATOMESH_INSTANCE:-http://web:41447}
DEBUG: ${DEBUG:-0}
FEDERATION: ${FEDERATION:-1}
PRIVATE: ${PRIVATE:-0}
+3
View File
@@ -0,0 +1,3 @@
target/
Cargo.lock
coverage.lcov
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "potatomesh-matrix-bridge"
version = "0.5.7"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.9"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
urlencoding = "2"
[dev-dependencies]
tempfile = "3"
mockito = "1"
serial_test = "3"
+19
View File
@@ -0,0 +1,19 @@
[potatomesh]
# Base URL without trailing slash
base_url = "https://potatomesh.net/api"
# Poll interval in seconds
poll_interval_secs = 60
[matrix]
# Homeserver base URL (client API) without trailing slash
homeserver = "https://matrix.example.org"
# Appservice access token (from your registration.yaml)
as_token = "YOUR_APPSERVICE_AS_TOKEN"
# Server name (domain) part of Matrix user IDs
server_name = "example.org"
# Room ID to send into (must be joined by the appservice / puppets)
room_id = "!yourroomid:example.org"
[state]
# Where to persist last seen message id (optional but recommended)
state_file = "bridge_state.json"
+249
View File
@@ -0,0 +1,249 @@
# potatomesh-matrix-bridge
A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix** room.
For each PotatoMesh node, the bridge creates (or uses) a **Matrix puppet user**:
- Matrix localpart: the hex node id (without `!`), e.g. `!67fc83cb``@67fc83cb:example.org`
- Matrix display name: the nodes `long_name` from the PotatoMesh API
Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users.
---
## Features
- Polls `https://potatomesh.net/api/messages` (or any configured base URL)
- Looks up node metadata via `GET /nodes/{hex}` and caches it
- One Matrix user per node:
- username: hex node id
- display name: `long_name`
- Forwards `TEXT_MESSAGE_APP` messages into a single Matrix room
- Persists last-seen message ID to avoid duplicates across restarts
---
## Architecture Overview
- **PotatoMesh side**
- `GET /messages` returns an array of messages
- `GET /nodes/{hex}` returns node metadata (including `long_name`)
- **Matrix side**
- Uses the Matrix Client-Server API with an **appservice access token**
- Impersonates puppet users via `user_id=@{hex}:{server_name}&access_token={as_token}`
- Sends `m.room.message` events into a configured room
This is **not** a full appservice framework; it just speaks the minimal HTTP needed.
---
## Requirements
- Rust (stable) and `cargo`
- A Matrix homeserver you control (e.g. Synapse)
- An **application service registration** on your homeserver that:
- Whitelists the puppet user namespace (e.g. `@[0-9a-f]{8}:example.org`)
- Provides an `as_token` the bridge can use
- Network access from the bridge host to:
- `https://potatomesh.net/api` (or your configured PotatoMesh API)
- Your Matrix homeserver (`https://matrix.example.org`)
---
## Configuration
All configuration is in `Config.toml` in the project root.
Example:
```toml
[potatomesh]
# Base URL without trailing slash
base_url = "https://potatomesh.net/api"
# Poll interval in seconds
poll_interval_secs = 10
[matrix]
# Homeserver base URL (client API) without trailing slash
homeserver = "https://matrix.example.org"
# Appservice access token (from your registration.yaml)
as_token = "YOUR_APPSERVICE_AS_TOKEN"
# Server name (domain) part of Matrix user IDs
server_name = "example.org"
# Room ID to send into (must be joined by the appservice / puppets)
room_id = "!yourroomid:example.org"
[state]
# Where to persist last seen message id
state_file = "bridge_state.json"
````
### PotatoMesh API
The bridge assumes:
* Messages: `GET {base_url}/messages` JSON array, for example:
```json
[
{
"id": 2947676906,
"rx_time": 1764241436,
"rx_iso": "2025-11-27T11:03:56Z",
"from_id": "!da6556d4",
"to_id": "^all",
"channel": 1,
"portnum": "TEXT_MESSAGE_APP",
"text": "Ping",
"rssi": -111,
"hop_limit": 1,
"lora_freq": 868,
"modem_preset": "MediumFast",
"channel_name": "TEST",
"snr": -9.0,
"node_id": "!06871773"
}
]
```
* Nodes: `GET {base_url}/nodes/{hex}` JSON, for example:
```json
{
"node_id": "!67fc83cb",
"short_name": "83CB",
"long_name": "Meshtastic 83CB",
"role": "CLIENT_HIDDEN",
"last_heard": 1764250515,
"first_heard": 1758993817,
"last_seen_iso": "2025-11-27T13:35:15Z"
}
```
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder as the Matrix localpart.
---
## Matrix Appservice Setup (Synapse example)
You need an appservice registration file (e.g. `potatomesh-bridge.yaml`) configured in Synapse.
A minimal example sketch (you **must** adjust URLs, secrets, namespaces):
```yaml
id: potatomesh-bridge
url: "http://your-bridge-host:8080" # not used by this bridge if it only calls out
as_token: "YOUR_APPSERVICE_AS_TOKEN"
hs_token: "SECRET_HS_TOKEN"
sender_localpart: "potatomesh-bridge"
rate_limited: false
namespaces:
users:
- exclusive: true
regex: "@[0-9a-f]{8}:example.org"
```
For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver.
In Synapses `homeserver.yaml`, add the registration file under `app_service_config_files`, restart, and invite a puppet user to your target room (or use room ID directly).
---
## Build
```bash
# clone
git clone https://github.com/YOUR_USER/potatomesh-matrix-bridge.git
cd potatomesh-matrix-bridge
# build
cargo build --release
```
The resulting binary will be at:
```bash
target/release/potatomesh-matrix-bridge
```
---
## Run
Ensure `Config.toml` is present and valid, then:
```bash
./target/release/potatomesh-matrix-bridge
```
Environment variables you may care about:
* `RUST_LOG` for logging, e.g.:
```bash
RUST_LOG=info,reqwest=warn ./target/release/potatomesh-matrix-bridge
```
The bridge will:
1. Load state from `bridge_state.json` (if present).
2. Poll PotatoMesh every `poll_interval_secs`.
3. For each new `TEXT_MESSAGE_APP`:
* Fetch node info.
* Ensure puppet is registered (`@{hex}:{server_name}`).
* Set puppet display name to `long_name`.
* Send a formatted text message into `room_id` as that puppet.
* Update and persist `bridge_state.json`.
Delete `bridge_state.json` if you want it to replay all currently available messages.
---
## Development
Run tests (currently mostly compile checks, no real tests yet):
```bash
cargo test
```
Format code:
```bash
cargo fmt
```
Lint (optional but recommended):
```bash
cargo clippy -- -D warnings
```
---
## GitHub Actions CI
This repository includes a GitHub Actions workflow (`.github/workflows/ci.yml`) that:
* runs on pushes and pull requests
* caches Cargo dependencies
* runs:
* `cargo fmt --check`
* `cargo clippy`
* `cargo test`
See the workflow file for details.
---
## Caveats & Future Work
* No E2EE: this bridge posts into unencrypted (or server-side managed) rooms. For encrypted rooms, youd need real E2EE support and key management.
* No inbound Matrix → PotatoMesh direction yet. This is a one-way bridge (PotatoMesh → Matrix).
* No pagination or `since` support on the PotatoMesh API. The bridge simply deduplicates by message `id` and stores the highest seen.
If you change the PotatoMesh API, adjust the types in `src/potatomesh.rs` accordingly.
+143
View File
@@ -0,0 +1,143 @@
use serde::Deserialize;
use std::{fs, path::Path};
#[derive(Debug, Deserialize, Clone)]
pub struct PotatomeshConfig {
pub base_url: String,
pub poll_interval_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct MatrixConfig {
pub homeserver: String,
pub as_token: String,
pub server_name: String,
pub room_id: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct StateConfig {
pub state_file: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub potatomesh: PotatomeshConfig,
pub matrix: MatrixConfig,
pub state: StateConfig,
}
impl Config {
pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
let contents = fs::read_to_string(path)?;
let cfg = toml::from_str(&contents)?;
Ok(cfg)
}
pub fn from_default_path() -> anyhow::Result<Self> {
let path = "Config.toml";
if !Path::new(path).exists() {
anyhow::bail!("Config file {path} not found");
}
Self::load_from_file(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
#[test]
fn parse_minimal_config_from_toml_str() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#;
let cfg: Config = toml::from_str(toml_str).expect("toml should parse");
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/api");
assert_eq!(cfg.potatomesh.poll_interval_secs, 10);
assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org");
assert_eq!(cfg.matrix.as_token, "AS_TOKEN");
assert_eq!(cfg.matrix.server_name, "example.org");
assert_eq!(cfg.matrix.room_id, "!roomid:example.org");
assert_eq!(cfg.state.state_file, "bridge_state.json");
}
#[test]
fn load_from_file_not_found() {
let result = Config::load_from_file("file_that_does_not_exist.toml");
assert!(result.is_err());
}
#[test]
fn load_from_file_valid_file() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", toml_str).unwrap();
let result = Config::load_from_file(file.path().to_str().unwrap());
assert!(result.is_ok());
}
#[test]
#[serial]
fn from_default_path_not_found() {
let tmp_dir = tempfile::tempdir().unwrap();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let result = Config::from_default_path();
assert!(result.is_err());
}
#[test]
#[serial]
fn from_default_path_found() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "AS_TOKEN"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "bridge_state.json"
"#;
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("Config.toml");
let mut file = std::fs::File::create(file_path).unwrap();
write!(file, "{}", toml_str).unwrap();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let result = Config::from_default_path();
assert!(result.is_ok());
}
}
+321
View File
@@ -0,0 +1,321 @@
mod config;
mod matrix;
mod potatomesh;
use std::{fs, path::Path};
use anyhow::Result;
use tokio::time::{sleep, Duration};
use tracing::{error, info};
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::{PotatoClient, PotatoMessage};
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct BridgeState {
last_message_id: Option<u64>,
}
impl BridgeState {
fn load(path: &str) -> Result<Self> {
if !Path::new(path).exists() {
return Ok(Self::default());
}
let data = fs::read_to_string(path)?;
let s: Self = serde_json::from_str(&data)?;
Ok(s)
}
fn save(&self, path: &str) -> Result<()> {
let data = serde_json::to_string_pretty(self)?;
fs::write(path, data)?;
Ok(())
}
fn should_forward(&self, msg: &PotatoMessage) -> bool {
match self.last_message_id {
None => true,
Some(last) => msg.id > last,
}
}
fn update_with(&mut self, msg: &PotatoMessage) {
self.last_message_id = Some(match self.last_message_id {
None => msg.id,
Some(last) => last.max(msg.id),
});
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("potatomesh_matrix_bridge=info".parse().unwrap_or_default())
.add_directive("reqwest=warn".parse().unwrap_or_default()),
)
.init();
let cfg = Config::from_default_path()?;
info!("Loaded config: {:?}", cfg);
let http = reqwest::Client::builder().build()?;
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
let state_path = &cfg.state.state_file;
let mut state = BridgeState::load(state_path)?;
info!("Loaded state: {:?}", state);
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
loop {
match potato.fetch_messages().await {
Ok(mut msgs) => {
// sort by id ascending so we process in order
msgs.sort_by_key(|m| m.id);
for msg in msgs {
if !state.should_forward(&msg) {
continue;
}
// Filter to the ports you care about
if msg.portnum != "TEXT_MESSAGE_APP" {
state.update_with(&msg);
continue;
}
if let Err(e) = handle_message(&potato, &matrix, &mut state, &msg).await {
error!("Error handling message {}: {:?}", msg.id, e);
}
// persist after each processed message
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
}
Err(e) => {
error!("Error fetching PotatoMesh messages: {:?}", e);
}
}
sleep(poll_interval).await;
}
}
async fn handle_message(
potato: &PotatoClient,
matrix: &MatrixAppserviceClient,
state: &mut BridgeState,
msg: &PotatoMessage,
) -> Result<()> {
let node = potato.get_node(&msg.node_id).await?;
let localpart = MatrixAppserviceClient::localpart_from_node_id(&msg.node_id);
let user_id = matrix.user_id(&localpart);
// Ensure puppet exists & has display name
matrix.ensure_user_registered(&localpart).await?;
matrix.set_display_name(&user_id, &node.long_name).await?;
// Format the bridged message
let short = node
.short_name
.clone()
.unwrap_or_else(|| node.long_name.clone());
let body = format!(
"[{short}] {text}\n({from_id}{to_id}, RSSI {rssi} dB, SNR {snr} dB, {chan}/{preset})",
short = short,
text = msg.text,
from_id = msg.from_id,
to_id = msg.to_id,
rssi = msg.rssi,
snr = msg.snr,
chan = msg.channel_name,
preset = msg.modem_preset,
);
matrix.send_text_message_as(&user_id, &body).await?;
state.update_with(msg);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{MatrixConfig, PotatomeshConfig};
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::PotatoClient;
fn sample_msg(id: u64) -> PotatoMessage {
PotatoMessage {
id,
rx_time: 0,
rx_iso: "2025-11-27T00:00:00Z".to_string(),
from_id: "!abcd1234".to_string(),
to_id: "^all".to_string(),
channel: 1,
portnum: "TEXT_MESSAGE_APP".to_string(),
text: "Ping".to_string(),
rssi: -100,
hop_limit: 1,
lora_freq: 868,
modem_preset: "MediumFast".to_string(),
channel_name: "TEST".to_string(),
snr: 0.0,
reply_id: None,
node_id: "!abcd1234".to_string(),
}
}
#[test]
fn bridge_state_initially_forwards_all() {
let state = BridgeState::default();
let msg = sample_msg(42);
assert!(state.should_forward(&msg));
}
#[test]
fn bridge_state_tracks_highest_id_and_skips_older() {
let mut state = BridgeState::default();
let m1 = sample_msg(10);
let m2 = sample_msg(20);
let m3 = sample_msg(15);
// First message, should forward
assert!(state.should_forward(&m1));
state.update_with(&m1);
assert_eq!(state.last_message_id, Some(10));
// Second message, higher id, should forward
assert!(state.should_forward(&m2));
state.update_with(&m2);
assert_eq!(state.last_message_id, Some(20));
// Third message, lower than last, should NOT forward
assert!(!state.should_forward(&m3));
// state remains unchanged
assert_eq!(state.last_message_id, Some(20));
}
#[test]
fn bridge_state_update_is_monotonic() {
let mut state = BridgeState {
last_message_id: Some(50),
};
let m = sample_msg(40);
state.update_with(&m); // id is lower than current
// last_message_id must stay at 50
assert_eq!(state.last_message_id, Some(50));
}
#[test]
fn bridge_state_load_save_roundtrip() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("state.json");
let path_str = file_path.to_str().unwrap();
let state = BridgeState {
last_message_id: Some(12345),
};
state.save(path_str).unwrap();
let loaded_state = BridgeState::load(path_str).unwrap();
assert_eq!(loaded_state.last_message_id, Some(12345));
}
#[test]
fn bridge_state_load_nonexistent() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("nonexistent.json");
let path_str = file_path.to_str().unwrap();
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
}
#[tokio::test]
async fn test_handle_message() {
let mut server = mockito::Server::new_async().await;
let potatomesh_cfg = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 1,
};
let matrix_cfg = MatrixConfig {
homeserver: server.url(),
as_token: "AS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
};
let node_id = "abcd1234";
let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name);
let encoded_user = urlencoding::encode(&user_id);
let mock_get_node = server
.mock("GET", "/nodes/abcd1234")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#)
.create();
let mock_register = server
.mock("POST", "/_matrix/client/v3/register")
.match_query("kind=user&access_token=AS_TOKEN")
.with_status(200)
.create();
let mock_display_name = server
.mock(
"PUT",
format!("/_matrix/client/v3/profile/{}/displayname", encoded_user).as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.with_status(200)
.create();
let http_client = reqwest::Client::new();
let matrix_client = MatrixAppserviceClient::new(http_client.clone(), matrix_cfg);
let room_id = &matrix_client.cfg.room_id;
let encoded_room = urlencoding::encode(room_id);
let txn_id = matrix_client
.txn_counter
.load(std::sync::atomic::Ordering::SeqCst);
let mock_send = server
.mock(
"PUT",
format!(
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
encoded_room, txn_id
)
.as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.with_status(200)
.create();
let potato_client = PotatoClient::new(http_client.clone(), potatomesh_cfg);
let mut state = BridgeState::default();
let msg = sample_msg(100);
let result = handle_message(&potato_client, &matrix_client, &mut state, &msg).await;
assert!(result.is_ok());
mock_get_node.assert();
mock_register.assert();
mock_display_name.assert();
mock_send.assert();
assert_eq!(state.last_message_id, Some(100));
}
}
+362
View File
@@ -0,0 +1,362 @@
use serde::Serialize;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use crate::config::MatrixConfig;
#[derive(Clone)]
pub struct MatrixAppserviceClient {
http: reqwest::Client,
pub cfg: MatrixConfig,
pub txn_counter: Arc<AtomicU64>,
}
impl MatrixAppserviceClient {
pub fn new(http: reqwest::Client, cfg: MatrixConfig) -> Self {
let start = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
Self {
http,
cfg,
txn_counter: Arc::new(AtomicU64::new(start)),
}
}
/// Convert a node_id like "!deadbeef" into Matrix localpart "deadbeef".
pub fn localpart_from_node_id(node_id: &str) -> String {
node_id.trim_start_matches('!').to_string()
}
/// Build a full Matrix user_id from localpart.
pub fn user_id(&self, localpart: &str) -> String {
format!("@{}:{}", localpart, self.cfg.server_name)
}
fn auth_query(&self) -> String {
format!("access_token={}", urlencoding::encode(&self.cfg.as_token))
}
/// Ensure the puppet user exists (register via appservice registration).
pub async fn ensure_user_registered(&self, localpart: &str) -> anyhow::Result<()> {
#[derive(Serialize)]
struct RegisterReq<'a> {
#[serde(rename = "type")]
typ: &'a str,
username: &'a str,
}
let url = format!(
"{}/_matrix/client/v3/register?kind=user&{}",
self.cfg.homeserver,
self.auth_query()
);
let body = RegisterReq {
typ: "m.login.application_service",
username: localpart,
};
let resp = self.http.post(&url).json(&body).send().await?;
if resp.status().is_success() {
Ok(())
} else {
// If user already exists, Synapse / HS usually returns 400 M_USER_IN_USE.
// We'll just ignore non-success and hope it's that case.
Ok(())
}
}
/// Set display name for puppet user.
pub async fn set_display_name(&self, user_id: &str, display_name: &str) -> anyhow::Result<()> {
#[derive(Serialize)]
struct DisplayNameReq<'a> {
displayname: &'a str,
}
let encoded_user = urlencoding::encode(user_id);
let url = format!(
"{}/_matrix/client/v3/profile/{}/displayname?user_id={}&{}",
self.cfg.homeserver,
encoded_user,
encoded_user,
self.auth_query()
);
let body = DisplayNameReq {
displayname: display_name,
};
let resp = self.http.put(&url).json(&body).send().await?;
if resp.status().is_success() {
Ok(())
} else {
// Non-fatal.
tracing::warn!(
"Failed to set display name for {}: {}",
user_id,
resp.status()
);
Ok(())
}
}
/// Send a plain text message into the configured room as puppet user_id.
pub async fn send_text_message_as(&self, user_id: &str, body_text: &str) -> anyhow::Result<()> {
#[derive(Serialize)]
struct MsgContent<'a> {
msgtype: &'a str,
body: &'a str,
}
let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst);
let encoded_room = urlencoding::encode(&self.cfg.room_id);
let encoded_user = urlencoding::encode(user_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}?user_id={}&{}",
self.cfg.homeserver,
encoded_room,
txn_id,
encoded_user,
self.auth_query()
);
let content = MsgContent {
msgtype: "m.text",
body: body_text,
};
let resp = self.http.put(&url).json(&content).send().await?;
if !resp.status().is_success() {
let status = resp.status();
// optional: pull a short body snippet for debugging
let body_snip = resp.text().await.unwrap_or_default();
// Log for observability
tracing::warn!(
"Failed to send message as {}: status {}, body: {}",
user_id,
status,
body_snip
);
// Propagate an error so callers know this message was NOT delivered
return Err(anyhow::anyhow!(
"Matrix send failed for {} with status {}",
user_id,
status
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_cfg() -> MatrixConfig {
MatrixConfig {
homeserver: "https://matrix.example.org".to_string(),
as_token: "AS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
}
}
#[test]
fn localpart_strips_bang_correctly() {
assert_eq!(
MatrixAppserviceClient::localpart_from_node_id("!deadbeef"),
"deadbeef"
);
assert_eq!(
MatrixAppserviceClient::localpart_from_node_id("cafebabe"),
"cafebabe"
);
}
#[test]
fn user_id_builds_from_localpart_and_server_name() {
let http = reqwest::Client::builder().build().unwrap();
let client = MatrixAppserviceClient::new(http, dummy_cfg());
let uid = client.user_id("deadbeef");
assert_eq!(uid, "@deadbeef:example.org");
}
#[test]
fn auth_query_contains_access_token() {
let http = reqwest::Client::builder().build().unwrap();
let client = MatrixAppserviceClient::new(http, dummy_cfg());
let q = client.auth_query();
assert!(q.starts_with("access_token="));
assert!(q.contains("AS_TOKEN"));
}
#[test]
fn test_new_matrix_client() {
let http_client = reqwest::Client::new();
let config = dummy_cfg();
let client = MatrixAppserviceClient::new(http_client, config);
assert_eq!(client.cfg.homeserver, "https://matrix.example.org");
assert_eq!(client.cfg.as_token, "AS_TOKEN");
assert!(client.txn_counter.load(Ordering::SeqCst) > 0);
}
#[tokio::test]
async fn test_ensure_user_registered_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/_matrix/client/v3/register")
.match_query("kind=user&access_token=AS_TOKEN")
.with_status(200)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_registered("testuser").await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_ensure_user_registered_user_in_use() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("POST", "/_matrix/client/v3/register")
.match_query("kind=user&access_token=AS_TOKEN")
.with_status(400) // M_USER_IN_USE
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_registered("testuser").await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_set_display_name_success() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let encoded_user = urlencoding::encode(user_id);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!("/_matrix/client/v3/profile/{}/displayname", encoded_user);
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(200)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.set_display_name(user_id, "Test Name").await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_set_display_name_fail_is_ok() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let encoded_user = urlencoding::encode(user_id);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!("/_matrix/client/v3/profile/{}/displayname", encoded_user);
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(500)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.set_display_name(user_id, "Test Name").await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_text_message_as_success() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
let encoded_user = urlencoding::encode(user_id);
let encoded_room = urlencoding::encode(room_id);
let client = {
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
MatrixAppserviceClient::new(reqwest::Client::new(), cfg)
};
let txn_id = client.txn_counter.load(Ordering::SeqCst);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!(
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
encoded_room, txn_id
);
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(200)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_text_message_as_fail() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
let encoded_user = urlencoding::encode(user_id);
let encoded_room = urlencoding::encode(room_id);
let client = {
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
MatrixAppserviceClient::new(reqwest::Client::new(), cfg)
};
let txn_id = client.txn_counter.load(Ordering::SeqCst);
let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!(
"/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
encoded_room, txn_id
);
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(500)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
mock.assert();
assert!(result.is_err());
}
}
+363
View File
@@ -0,0 +1,363 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::PotatomeshConfig;
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
pub struct PotatoMessage {
pub id: u64,
pub rx_time: u64,
pub rx_iso: String,
pub from_id: String,
pub to_id: String,
pub channel: u8,
pub portnum: String,
pub text: String,
pub rssi: i16,
pub hop_limit: u8,
pub lora_freq: u32,
pub modem_preset: String,
pub channel_name: String,
pub snr: f32,
#[serde(default)]
pub reply_id: Option<u64>,
pub node_id: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
pub struct PotatoNode {
pub node_id: String,
#[serde(default)]
pub short_name: Option<String>,
pub long_name: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub hw_model: Option<String>,
#[serde(default)]
pub last_heard: Option<u64>,
#[serde(default)]
pub first_heard: Option<u64>,
#[serde(default)]
pub latitude: Option<f64>,
#[serde(default)]
pub longitude: Option<f64>,
#[serde(default)]
pub altitude: Option<f64>,
}
#[derive(Clone)]
pub struct PotatoClient {
http: reqwest::Client,
cfg: PotatomeshConfig,
// simple in-memory cache for node metadata
nodes_cache: Arc<RwLock<HashMap<String, PotatoNode>>>,
}
impl PotatoClient {
pub fn new(http: reqwest::Client, cfg: PotatomeshConfig) -> Self {
Self {
http,
cfg,
nodes_cache: Arc::new(RwLock::new(HashMap::new())),
}
}
fn messages_url(&self) -> String {
format!("{}/messages", self.cfg.base_url)
}
fn node_url(&self, hex_id: &str) -> String {
// e.g. https://potatomesh.net/api/nodes/67fc83cb
format!("{}/nodes/{}", self.cfg.base_url, hex_id)
}
pub async fn fetch_messages(&self) -> anyhow::Result<Vec<PotatoMessage>> {
let resp = self
.http
.get(self.messages_url())
.send()
.await?
.error_for_status()?;
let msgs: Vec<PotatoMessage> = resp.json().await?;
Ok(msgs)
}
pub async fn get_node(&self, node_id_with_bang: &str) -> anyhow::Result<PotatoNode> {
// node_id is like "!67fc83cb" → we need "67fc83cb"
let hex = node_id_with_bang.trim_start_matches('!').to_string();
{
let cache = self.nodes_cache.read().await;
if let Some(n) = cache.get(&hex) {
return Ok(n.clone());
}
}
let url = self.node_url(&hex);
let resp = self.http.get(url).send().await?.error_for_status()?;
let node: PotatoNode = resp.json().await?;
{
let mut cache = self.nodes_cache.write().await;
cache.insert(hex, node.clone());
}
Ok(node)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_sample_message_array() {
let json = r#"
[
{
"id": 2947676906,
"rx_time": 1764241436,
"rx_iso": "2025-11-27T11:03:56Z",
"from_id": "!da6556d4",
"to_id": "^all",
"channel": 1,
"portnum": "TEXT_MESSAGE_APP",
"text": "Ping",
"rssi": -111,
"hop_limit": 1,
"lora_freq": 868,
"modem_preset": "MediumFast",
"channel_name": "TEST",
"snr": -9.0,
"node_id": "!06871773"
}
]
"#;
let msgs: Vec<PotatoMessage> = serde_json::from_str(json).expect("valid message json");
assert_eq!(msgs.len(), 1);
let m = &msgs[0];
assert_eq!(m.id, 2947676906);
assert_eq!(m.from_id, "!da6556d4");
assert_eq!(m.node_id, "!06871773");
assert_eq!(m.portnum, "TEXT_MESSAGE_APP");
assert_eq!(m.lora_freq, 868);
assert!((m.snr - (-9.0)).abs() < f32::EPSILON);
}
#[test]
fn deserialize_sample_node() {
let json = r#"
{
"node_id": "!67fc83cb",
"short_name": "83CB",
"long_name": "Meshtastic 83CB",
"role": "CLIENT_HIDDEN",
"last_heard": 1764250515,
"first_heard": 1758993817,
"last_seen_iso": "2025-11-27T13:35:15Z"
}
"#;
let node: PotatoNode = serde_json::from_str(json).expect("valid node json");
assert_eq!(node.node_id, "!67fc83cb");
assert_eq!(node.short_name.as_deref(), Some("83CB"));
assert_eq!(node.long_name, "Meshtastic 83CB");
assert_eq!(node.role.as_deref(), Some("CLIENT_HIDDEN"));
assert_eq!(node.last_heard, Some(1764250515));
assert_eq!(node.first_heard, Some(1758993817));
assert!(node.latitude.is_none());
}
#[test]
fn node_hex_id_is_stripped_correctly() {
let with_bang = "!deadbeef";
let hex = with_bang.trim_start_matches('!');
assert_eq!(hex, "deadbeef");
let already_hex = "cafebabe";
let hex2 = already_hex.trim_start_matches('!');
assert_eq!(hex2, "cafebabe");
}
#[test]
fn test_new_potato_client() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(client.cfg.base_url, "http://localhost:8080");
assert_eq!(client.cfg.poll_interval_secs, 60);
}
#[test]
fn test_messages_url() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(client.messages_url(), "http://localhost:8080/messages");
}
#[test]
fn test_node_url() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(
client.node_url("!1234"),
"http://localhost:8080/nodes/!1234"
);
}
#[tokio::test]
async fn test_fetch_messages_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/messages")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"
[
{
"id": 2947676906, "rx_time": 1764241436, "rx_iso": "2025-11-27T11:03:56Z",
"from_id": "!da6556d4", "to_id": "^all", "channel": 1,
"portnum": "TEXT_MESSAGE_APP", "text": "Ping", "rssi": -111,
"hop_limit": 1, "lora_freq": 868, "modem_preset": "MediumFast",
"channel_name": "TEST", "snr": -9.0, "node_id": "!06871773"
}
]
"#,
)
.create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.fetch_messages().await;
mock.assert();
assert!(result.is_ok());
let messages = result.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, 2947676906);
}
#[tokio::test]
async fn test_fetch_messages_error() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/messages").with_status(500).create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.fetch_messages().await;
mock.assert();
assert!(result.is_err());
}
#[tokio::test]
async fn test_get_node_cache_hit() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let node = PotatoNode {
node_id: "!1234".to_string(),
short_name: Some("test".to_string()),
long_name: "test node".to_string(),
role: None,
hw_model: None,
last_heard: None,
first_heard: None,
latitude: None,
longitude: None,
altitude: None,
};
client
.nodes_cache
.write()
.await
.insert("1234".to_string(), node.clone());
let result = client.get_node("!1234").await;
assert!(result.is_ok());
let got = result.unwrap();
assert_eq!(got.node_id, "!1234");
assert_eq!(got.short_name.unwrap(), "test");
}
#[tokio::test]
async fn test_get_node_cache_miss() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/nodes/1234")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"
{
"node_id": "!1234", "short_name": "test", "long_name": "test node",
"role": "test", "hw_model": "test", "last_heard": 1, "first_heard": 1,
"latitude": 1.0, "longitude": 1.0, "altitude": 1.0
}
"#,
)
.create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
// first call, should miss cache and hit the server
let result = client.get_node("!1234").await;
mock.assert();
assert!(result.is_ok());
// second call, should hit cache
let result2 = client.get_node("!1234").await;
assert!(result2.is_ok());
// mockito would panic here if we made a second request
}
#[tokio::test]
async fn test_get_node_error() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/nodes/1234").with_status(500).create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.get_node("!1234").await;
mock.assert();
assert!(result.is_err());
}
}
+58
View File
@@ -223,6 +223,64 @@ def mesh_module(monkeypatch):
sys.modules.pop(module_name, None)
def test_instance_domain_prefers_primary_env(mesh_module, monkeypatch):
"""Ensure the ingestor prefers ``INSTANCE_DOMAIN`` over the legacy variable."""
monkeypatch.setenv("INSTANCE_DOMAIN", "https://new.example")
monkeypatch.setenv("POTATOMESH_INSTANCE", "https://legacy.example")
try:
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
assert refreshed_instance == "https://new.example"
assert mesh_module.INSTANCE == "https://new.example"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
monkeypatch.delenv("POTATOMESH_INSTANCE", raising=False)
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
def test_instance_domain_falls_back_to_legacy(mesh_module, monkeypatch):
"""Verify ``POTATOMESH_INSTANCE`` is used when ``INSTANCE_DOMAIN`` is unset."""
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
monkeypatch.setenv("POTATOMESH_INSTANCE", "https://legacy-only.example")
try:
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
assert refreshed_instance == "https://legacy-only.example"
assert mesh_module.INSTANCE == "https://legacy-only.example"
finally:
monkeypatch.delenv("POTATOMESH_INSTANCE", raising=False)
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
def test_instance_domain_infers_scheme_for_hostnames(mesh_module, monkeypatch):
"""Ensure bare hostnames are promoted to HTTPS URLs for ingestion."""
monkeypatch.setenv("INSTANCE_DOMAIN", "mesh.example.org")
monkeypatch.delenv("POTATOMESH_INSTANCE", raising=False)
try:
refreshed_instance = mesh_module.config._resolve_instance_domain()
mesh_module.config.INSTANCE = refreshed_instance
mesh_module.INSTANCE = refreshed_instance
assert refreshed_instance == "https://mesh.example.org"
assert mesh_module.INSTANCE == "https://mesh.example.org"
finally:
monkeypatch.delenv("INSTANCE_DOMAIN", raising=False)
mesh_module.config.INSTANCE = mesh_module.config._resolve_instance_domain()
mesh_module.INSTANCE = mesh_module.config.INSTANCE
def test_subscribe_receive_topics_covers_all_handlers(mesh_module, monkeypatch):
mesh = mesh_module
daemon_mod = sys.modules["data.mesh_ingestor.daemon"]
@@ -1262,7 +1262,7 @@ module PotatoMesh
rx_time = now if rx_time.nil? || rx_time > now
rx_iso = string_or_nil(payload["rx_iso"]) || Time.at(rx_time).utc.iso8601
metrics = normalize_json_object(payload["metrics"])
metrics = normalize_json_object(payload["metrics"]) || {}
src = coerce_integer(payload["src"] || payload["source"] || payload["from"])
dest = coerce_integer(payload["dest"] || payload["destination"] || payload["to"])
rssi = coerce_integer(payload["rssi"]) || coerce_integer(metrics["rssi"])
@@ -164,6 +164,16 @@ module PotatoMesh
db.execute_batch(File.read(sql_file))
end
instance_columns = db.execute("PRAGMA table_info(instances)").map { |row| row[1] }
unless instance_columns.include?("contact_link")
db.execute("ALTER TABLE instances ADD COLUMN contact_link TEXT")
instance_columns << "contact_link"
end
unless instance_columns.include?("nodes_count")
db.execute("ALTER TABLE instances ADD COLUMN nodes_count INTEGER")
end
telemetry_tables =
db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='telemetry'").flatten
if telemetry_tables.empty?
+55 -3
View File
@@ -61,6 +61,7 @@ module PotatoMesh
def self_instance_attributes
domain = self_instance_domain
last_update = latest_node_update_timestamp || Time.now.to_i
nodes_count = active_node_count_since(Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age)
{
id: app_constant(:SELF_INSTANCE_ID),
domain: domain,
@@ -73,9 +74,37 @@ module PotatoMesh
longitude: PotatoMesh::Config.map_center_lon,
last_update_time: last_update,
is_private: private_mode?,
contact_link: sanitized_contact_link,
nodes_count: nodes_count,
}
end
# Count the number of nodes active since the supplied timestamp.
#
# @param cutoff [Integer] unix timestamp in seconds.
# @param db [SQLite3::Database, nil] optional open handle to reuse.
# @return [Integer, nil] node count or nil when unavailable.
def active_node_count_since(cutoff, db: nil)
return nil unless cutoff
handle = db || open_database(readonly: true)
count =
with_busy_retry do
handle.get_first_value("SELECT COUNT(*) FROM nodes WHERE last_heard >= ?", cutoff.to_i)
end
Integer(count)
rescue SQLite3::Exception, ArgumentError => e
warn_log(
"Failed to count active nodes",
context: "instances.nodes_count",
error_class: e.class.name,
error_message: e.message,
)
nil
ensure
handle&.close unless db
end
def sign_instance_attributes(attributes)
payload = canonical_instance_payload(attributes)
Base64.strict_encode64(
@@ -96,6 +125,7 @@ module PotatoMesh
"longitude" => attributes[:longitude],
"lastUpdateTime" => attributes[:last_update_time],
"isPrivate" => attributes[:is_private],
"contactLink" => attributes[:contact_link],
"signature" => signature,
}
payload.reject { |_, value| value.nil? }
@@ -450,6 +480,7 @@ module PotatoMesh
def canonical_instance_payload(attributes)
data = {}
data["contactLink"] = attributes[:contact_link] if attributes[:contact_link]
data["id"] = attributes[:id] if attributes[:id]
data["domain"] = attributes[:domain] if attributes[:domain]
data["pubkey"] = attributes[:pubkey] if attributes[:pubkey]
@@ -611,6 +642,7 @@ module PotatoMesh
longitude: coerce_float(payload["longitude"]),
last_update_time: coerce_integer(payload["lastUpdateTime"]),
is_private: private_flag,
contact_link: string_or_nil(payload["contactLink"]),
}
[attributes, signature, nil]
@@ -719,6 +751,7 @@ module PotatoMesh
end
processed_entries = 0
recent_cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
payload.each do |entry|
if per_response_limit && per_response_limit.positive? && processed_entries >= per_response_limit
debug_log(
@@ -773,13 +806,27 @@ module PotatoMesh
attributes[:is_private] = false if attributes[:is_private].nil?
nodes_since_path = "/api/nodes?since=#{recent_cutoff}"
nodes_since_window, nodes_since_metadata = fetch_instance_json(attributes[:domain], nodes_since_path)
if nodes_since_window.is_a?(Array)
attributes[:nodes_count] = nodes_since_window.length
elsif nodes_since_metadata
warn_log(
"Failed to load remote node window",
context: "federation.instances",
domain: attributes[:domain],
reason: Array(nodes_since_metadata).map(&:to_s).join("; "),
)
end
remote_nodes, node_metadata = fetch_instance_json(attributes[:domain], "/api/nodes")
remote_nodes ||= nodes_since_window if nodes_since_window.is_a?(Array)
unless remote_nodes
warn_log(
"Failed to load remote node data",
context: "federation.instances",
domain: attributes[:domain],
reason: Array(node_metadata).map(&:to_s).join("; "),
reason: Array(node_metadata || nodes_since_metadata).map(&:to_s).join("; "),
)
next
end
@@ -1055,8 +1102,8 @@ module PotatoMesh
sql = <<~SQL
INSERT INTO instances (
id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
domain=excluded.domain,
pubkey=excluded.pubkey,
@@ -1068,9 +1115,12 @@ module PotatoMesh
longitude=excluded.longitude,
last_update_time=excluded.last_update_time,
is_private=excluded.is_private,
nodes_count=excluded.nodes_count,
contact_link=excluded.contact_link,
signature=excluded.signature
SQL
nodes_count = coerce_integer(attributes[:nodes_count])
params = [
attributes[:id],
normalized_domain,
@@ -1083,6 +1133,8 @@ module PotatoMesh
attributes[:longitude],
attributes[:last_update_time],
attributes[:is_private] ? 1 : 0,
nodes_count,
attributes[:contact_link],
signature,
]
+3 -1
View File
@@ -143,6 +143,8 @@ module PotatoMesh
"longitude" => coerce_float(row["longitude"]),
"lastUpdateTime" => last_update_time,
"isPrivate" => private_flag,
"nodesCount" => coerce_integer(row["nodes_count"]),
"contactLink" => string_or_nil(row["contact_link"]),
"signature" => signature,
}
@@ -173,7 +175,7 @@ module PotatoMesh
min_last_update_time = now - PotatoMesh::Config.week_seconds
sql = <<~SQL
SELECT id, domain, pubkey, name, version, channel, frequency,
latitude, longitude, last_update_time, is_private, signature
latitude, longitude, last_update_time, is_private, nodes_count, contact_link, signature
FROM instances
WHERE domain IS NOT NULL AND TRIM(domain) != ''
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
+67 -6
View File
@@ -20,6 +20,7 @@ module PotatoMesh
MAX_QUERY_LIMIT = 1000
DEFAULT_TELEMETRY_WINDOW_SECONDS = 86_400
DEFAULT_TELEMETRY_BUCKET_SECONDS = 300
TELEMETRY_ZERO_INVALID_COLUMNS = %w[battery_level voltage].freeze
TELEMETRY_AGGREGATE_COLUMNS =
%w[
battery_level
@@ -48,6 +49,9 @@ module PotatoMesh
soil_moisture
soil_temperature
].freeze
TELEMETRY_AGGREGATE_SCALERS = {
"current" => 0.001,
}.freeze
# Remove nil or empty values from an API response hash to reduce payload size
# while preserving legitimate zero-valued measurements.
@@ -78,6 +82,19 @@ module PotatoMesh
end
end
# Treat zero-valued telemetry measurements that are known to be invalid
# (such as battery level or voltage) as missing data so they are omitted
# from API responses. Metrics that can legitimately be zero will remain
# untouched when routed through this helper.
#
# @param value [Numeric, nil] telemetry measurement.
# @return [Numeric, nil] nil when the value is zero, otherwise the original value.
def nil_if_zero(value)
return nil if value.respond_to?(:zero?) && value.zero?
value
end
# Normalise a caller-provided limit to a sane, positive integer.
#
# @param limit [Object] value coerced to an integer.
@@ -470,8 +487,8 @@ module PotatoMesh
r["rssi"] = coerce_integer(r["rssi"])
r["bitfield"] = coerce_integer(r["bitfield"])
r["snr"] = coerce_float(r["snr"])
r["battery_level"] = coerce_float(r["battery_level"])
r["voltage"] = coerce_float(r["voltage"])
r["battery_level"] = sanitize_zero_invalid_metric("battery_level", coerce_float(r["battery_level"]))
r["voltage"] = sanitize_zero_invalid_metric("voltage", coerce_float(r["voltage"]))
r["channel_utilization"] = coerce_float(r["channel_utilization"])
r["air_util_tx"] = coerce_float(r["air_util_tx"])
r["uptime_seconds"] = coerce_integer(r["uptime_seconds"])
@@ -479,7 +496,8 @@ module PotatoMesh
r["relative_humidity"] = coerce_float(r["relative_humidity"])
r["barometric_pressure"] = coerce_float(r["barometric_pressure"])
r["gas_resistance"] = coerce_float(r["gas_resistance"])
r["current"] = coerce_float(r["current"])
current_ma = coerce_float(r["current"])
r["current"] = current_ma.nil? ? nil : current_ma / 1000.0
r["iaq"] = coerce_integer(r["iaq"])
r["distance"] = coerce_float(r["distance"])
r["lux"] = coerce_float(r["lux"])
@@ -521,9 +539,10 @@ module PotatoMesh
]
TELEMETRY_AGGREGATE_COLUMNS.each do |column|
select_clauses << "AVG(#{column}) AS #{column}_avg"
select_clauses << "MIN(#{column}) AS #{column}_min"
select_clauses << "MAX(#{column}) AS #{column}_max"
aggregate_source = telemetry_aggregate_source(column)
select_clauses << "AVG(#{aggregate_source}) AS #{column}_avg"
select_clauses << "MIN(#{aggregate_source}) AS #{column}_min"
select_clauses << "MAX(#{aggregate_source}) AS #{column}_max"
end
sql = <<~SQL
@@ -549,8 +568,18 @@ module PotatoMesh
avg = coerce_float(row["#{column}_avg"])
min_value = coerce_float(row["#{column}_min"])
max_value = coerce_float(row["#{column}_max"])
scale = TELEMETRY_AGGREGATE_SCALERS[column]
if scale
avg *= scale unless avg.nil?
min_value *= scale unless min_value.nil?
max_value *= scale unless max_value.nil?
end
metrics = {}
avg = sanitize_zero_invalid_metric(column, avg)
min_value = sanitize_zero_invalid_metric(column, min_value)
max_value = sanitize_zero_invalid_metric(column, max_value)
metrics["avg"] = avg unless avg.nil?
metrics["min"] = min_value unless min_value.nil?
metrics["max"] = max_value unless max_value.nil?
@@ -578,12 +607,44 @@ module PotatoMesh
db&.close
end
# Normalise telemetry metrics that cannot legitimately be zero so API
# consumers do not mistake absent readings for valid measurements. Values
# for fields such as battery level and voltage are treated as missing data
# when they are zero.
#
# @param column [String] telemetry metric name.
# @param value [Numeric, nil] raw metric value.
# @return [Numeric, nil] metric value or nil when zero is invalid.
def sanitize_zero_invalid_metric(column, value)
return nil_if_zero(value) if TELEMETRY_ZERO_INVALID_COLUMNS.include?(column)
value
end
# Choose the SQL expression used to aggregate telemetry metrics. Metrics
# that cannot legitimately be zero are wrapped in a NULLIF to ensure
# invalid zero readings are ignored by aggregate functions such as AVG,
# MIN, and MAX, aligning the database semantics with API-level
# zero-as-missing handling.
#
# @param column [String] telemetry metric name.
# @return [String] SQL fragment used in aggregate expressions.
def telemetry_aggregate_source(column)
return "NULLIF(#{column}, 0)" if TELEMETRY_ZERO_INVALID_COLUMNS.include?(column)
column
end
def query_traces(limit, node_ref: nil)
limit = coerce_query_limit(limit)
db = open_database(readonly: true)
db.results_as_hash = true
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << min_rx_time
if node_ref
tokens = node_reference_tokens(node_ref)
@@ -186,6 +186,11 @@ module PotatoMesh
render_root_view(:charts, view_mode: :charts)
end
app.get %r{/federation/?} do
halt 404 unless federation_enabled?
render_root_view(:federation, view_mode: :federation)
end
app.get "/nodes/:id" do
node_ref = params.fetch("id", nil)
reference_payload = build_node_detail_reference(node_ref)
+1 -1
View File
@@ -175,7 +175,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"0.5.6"
"0.5.7"
end
# Default refresh interval for frontend polling routines.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.6",
"version": "0.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.6",
"version": "0.5.7",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.6",
"version": "0.5.7",
"type": "module",
"private": true,
"scripts": {
@@ -168,6 +168,7 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
telemetry: [{ node_id: nodeId, rx_time: NOW - 30 }],
positions: [{ node_id: nodeId, rx_time: NOW - 20 }],
neighbors: [{ node_id: nodeId, neighbor_id: neighborId, rx_time: NOW - 10 }],
traces: [{ id: 5_000, src: nodeId, hops: [neighborId], dest: '!charlie', rx_time: NOW - 5 }],
messages: [],
nowSeconds: NOW,
windowSeconds: WINDOW
@@ -178,11 +179,35 @@ test('buildChatTabModel includes telemetry, position, and neighbor events', () =
CHAT_LOG_ENTRY_TYPES.NODE_INFO,
CHAT_LOG_ENTRY_TYPES.TELEMETRY,
CHAT_LOG_ENTRY_TYPES.POSITION,
CHAT_LOG_ENTRY_TYPES.NEIGHBOR
CHAT_LOG_ENTRY_TYPES.NEIGHBOR,
CHAT_LOG_ENTRY_TYPES.TRACE
]);
assert.equal(model.logEntries[0].nodeId, nodeId);
const lastEntry = model.logEntries[model.logEntries.length - 1];
assert.equal(lastEntry.neighborId, neighborId);
const neighborEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.NEIGHBOR);
assert.ok(neighborEntry);
assert.equal(neighborEntry.neighborId, neighborId);
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
assert.ok(traceEntry);
assert.deepEqual(traceEntry.traceLabels, [nodeId, neighborId, '!charlie']);
});
test('buildChatTabModel normalises numeric traceroute hops into canonical IDs', () => {
const source = 0xabcdef01;
const hops = ['0xABCDEF02', '!abcdef03', 123];
const dest = 0xabcdef04;
const model = buildChatTabModel({
nodes: [],
traces: [{ rx_time: NOW - 5, src: source, hops, dest }],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const traceEntry = model.logEntries.find(entry => entry.type === CHAT_LOG_ENTRY_TYPES.TRACE);
assert.ok(traceEntry);
assert.equal(traceEntry.nodeId, '!abcdef01');
assert.deepEqual(
traceEntry.tracePath.map(hop => hop.id),
['!abcdef01', '!abcdef02', '!abcdef03', '!0000007b', '!abcdef04']
);
});
test('buildChatTabModel merges dedicated encrypted log feed without altering channels', () => {
@@ -74,6 +74,18 @@ test('chatLogEntryMatchesQuery inspects neighbor node context', () => {
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
});
test('chatLogEntryMatchesQuery inspects traceroute hop labels', () => {
const entry = {
type: CHAT_LOG_ENTRY_TYPES.TRACE,
traceLabels: ['!alpha', '!bravo', '!charlie'],
tracePath: [{ id: '!alpha' }, { id: '!bravo' }, { id: '!charlie' }]
};
const query = normaliseChatFilterQuery('bravo');
assert.equal(chatLogEntryMatchesQuery(entry, query), true);
const missQuery = normaliseChatFilterQuery('delta');
assert.equal(chatLogEntryMatchesQuery(entry, missQuery), false);
});
test('filterChatModel filters both log entries and channel messages', () => {
const model = {
logEntries: [
@@ -104,6 +104,7 @@ class MockElement {
this.style = {};
this.textContent = '';
this.classList = new MockClassList();
this.childNodes = [];
}
/**
@@ -129,6 +130,113 @@ class MockElement {
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
/**
* Remove an attribute from the element.
*
* @param {string} name Attribute identifier.
* @returns {void}
*/
removeAttribute(name) {
this.attributes.delete(name);
}
/**
* Append a child node to this element.
*
* @param {Object} node Child node to append.
* @returns {Object} Appended node.
*/
appendChild(node) {
this.childNodes.push(node);
return node;
}
/**
* Replace all existing children with the provided nodes.
*
* @param {...Object} nodes Child nodes to set on the element.
* @returns {void}
*/
replaceChildren(...nodes) {
const expanded = [];
nodes.forEach(node => {
if (node && node.tagName === 'FRAGMENT' && Array.isArray(node.childNodes)) {
expanded.push(...node.childNodes);
} else {
expanded.push(node);
}
});
this.childNodes = expanded;
}
/**
* Serialize the element's children into a naive HTML string for test
* assertions. This intentionally covers only the subset of markup produced
* in unit tests.
*
* @returns {string} Serialized HTML content.
*/
get innerHTML() {
return this.childNodes
.map(node => {
if (typeof node === 'string') return node;
if (node && node.tagName) {
const attrs = [];
if (node.attributes.size) {
node.attributes.forEach((value, key) => {
attrs.push(`${key}="${value}"`);
});
}
const classAttr = node.classList && node.classList._values && node.classList._values.size
? `class="${Array.from(node.classList._values).join(' ')}"`
: null;
if (classAttr) attrs.push(classAttr);
const children = node.innerHTML || '';
return `<${node.tagName.toLowerCase()}${attrs.length ? ' ' + attrs.join(' ') : ''}>${children}</${node.tagName.toLowerCase()}>`;
}
return '';
})
.join('');
}
/**
* Setter to overwrite children from a raw HTML string in tests. This is a
* minimal stub and only supports plain text content insertion.
*
* @param {string} value Raw HTML content.
* @returns {void}
*/
set innerHTML(value) {
this.childNodes = [String(value)];
}
/**
* Very small querySelectorAll implementation that supports ``.class`` lookups
* used in unit tests.
*
* @param {string} selector CSS selector (class names only).
* @returns {Array<MockElement>} Matching child nodes.
*/
querySelectorAll(selector) {
if (!selector || typeof selector !== 'string') return [];
const classMatch = selector.match(/^\.(.+)$/);
if (!classMatch) return [];
const className = classMatch[1];
const matches = [];
const visit = node => {
if (node && node.classList && typeof node.classList.contains === 'function') {
if (node.classList.contains(className)) {
matches.push(node);
}
}
if (node && Array.isArray(node.childNodes)) {
node.childNodes.forEach(child => visit(child));
}
};
visit(this);
return matches;
}
}
/**
@@ -182,8 +290,9 @@ export function createDomEnvironment(options = {}) {
documentListeners.delete(event);
},
dispatchEvent(event) {
const handler = documentListeners.get(event);
if (handler) handler();
const key = typeof event === 'string' ? event : event?.type;
const handler = documentListeners.get(key);
if (handler) handler(event);
},
getElementById(id) {
return registry.get(id) || null;
@@ -193,6 +302,18 @@ export function createDomEnvironment(options = {}) {
},
createElement(tagName) {
return new MockElement(tagName, registry);
},
createDocumentFragment() {
const fragment = new MockElement('fragment', null);
fragment.childNodes = [];
fragment.appendChild = node => {
fragment.childNodes.push(node);
return node;
};
fragment.replaceChildren = (...nodes) => {
fragment.childNodes = [...nodes];
};
return fragment;
}
};
@@ -218,8 +339,9 @@ export function createDomEnvironment(options = {}) {
windowListeners.delete(event);
},
dispatchEvent(event) {
const handler = windowListeners.get(event);
if (handler) handler();
const key = typeof event === 'string' ? event : event?.type;
const handler = windowListeners.get(key);
if (handler) handler(event);
},
getComputedStyle(target) {
if (typeof computedStyleImpl === 'function') {
@@ -0,0 +1,54 @@
/*
* 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 { createDomEnvironment } from './dom-environment.js';
test('dom environment supports class queries and innerHTML setter', () => {
const env = createDomEnvironment({ includeBody: true });
const { document, createElement, cleanup } = env;
const parent = createElement('div');
const child = createElement('span');
child.classList.add('leaflet-tile');
child.setAttribute('data-test', 'ok');
parent.appendChild(child);
const matches = parent.querySelectorAll('.leaflet-tile');
assert.equal(matches.length, 1);
assert.equal(matches[0], child);
const target = createElement('div');
target.innerHTML = '<b>hello</b>';
assert.match(target.innerHTML, /hello/);
const fragment = document.createDocumentFragment();
fragment.replaceChildren(createElement('p'));
const container = createElement('section');
const decorated = createElement('span');
decorated.setAttribute('data-id', '123');
decorated.classList.add('foo');
container.appendChild(decorated);
assert.match(container.innerHTML, /data-id="123"/);
assert.match(container.innerHTML, /class="foo"/);
container.replaceChildren(createElement('div')); // cover non-fragment path
container.childNodes.push({}); // cover empty serialization branch
assert.ok(container.innerHTML.includes('<div'));
cleanup();
});
@@ -0,0 +1,479 @@
/*
* 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 { createDomEnvironment } from './dom-environment.js';
import { initializeFederationPage } from '../federation-page.js';
import { roleColors } from '../role-helpers.js';
test('federation map centers on configured coordinates and follows theme filters', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: true });
const { document, window, createElement, registerElement, cleanup } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
const configPayload = {
mapCenter: { lat: 10, lon: 20 },
mapZoom: 7,
tileFilters: { light: 'brightness(1)', dark: 'invert(1)' }
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
const tileContainer = createElement('div');
const tilePane = createElement('div');
const tileImage = createElement('img');
tileImage.classList.add('leaflet-tile');
tileContainer.appendChild(tileImage);
tilePane.appendChild(tileImage);
const mapSetViewCalls = [];
const mapFitBoundsCalls = [];
const circleMarkerCalls = [];
const tileLayerStub = {
addTo() {
return this;
},
getContainer() {
return tileContainer;
},
on(event, handler) {
if (event === 'load') {
this._onLoad = handler;
}
}
};
const mapStub = {
setView(...args) {
mapSetViewCalls.push(args);
},
on() {},
getPane(name) {
return name === 'tilePane' ? tilePane : null;
},
fitBounds(...args) {
mapFitBoundsCalls.push(args);
}
};
const leafletStub = {
map() {
return mapStub;
},
tileLayer() {
return tileLayerStub;
},
layerGroup() {
return {
addLayer() {},
addTo() {
return this;
}
};
},
circleMarker(latlng, options) {
circleMarkerCalls.push({ latlng, options });
return {
bindPopup() {
return this;
}
};
}
};
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'alpha.mesh',
contactLink: 'https://chat.alpha',
version: '1.0.0',
latitude: 10.12345,
longitude: -20.98765,
lastUpdateTime: Math.floor(Date.now() / 1000) - 90,
nodesCount: 12
},
{
domain: 'bravo.mesh',
contactLink: null,
version: '2.0.0',
lastUpdateTime: Math.floor(Date.now() / 1000) - (2 * 86400),
nodesCount: 2
}
]
});
try {
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
assert.deepEqual(mapSetViewCalls[0], [[10, 20], 7]);
assert.equal(tileContainer.style.filter, 'invert(1)');
assert.equal(tilePane.style.filter, 'invert(1)');
assert.equal(tileImage.style.filter, 'invert(1)');
document.body.classList.remove('dark');
document.documentElement.setAttribute('data-theme', 'light');
window.dispatchEvent({ type: 'themechange', detail: { theme: 'light' } });
assert.equal(tileContainer.style.filter, 'brightness(1)');
assert.equal(tilePane.style.filter, 'brightness(1)');
assert.equal(tileImage.style.filter, 'brightness(1)');
document.documentElement.removeAttribute('data-theme');
document.body.classList.remove('dark');
window.dispatchEvent({ type: 'themechange', detail: { theme: null } });
assert.equal(tileContainer.style.filter, 'invert(1)');
const rows = tbodyEl.childNodes;
assert.equal(rows.length, 2);
const firstRowHtml = rows[0].innerHTML;
assert.match(firstRowHtml, /alpha\.mesh/);
assert.match(firstRowHtml, /https:\/\/chat\.alpha/);
assert.match(firstRowHtml, /10\.12345/);
assert.match(firstRowHtml, /-20\.98765/);
assert.match(firstRowHtml, />12</);
assert.match(firstRowHtml, /ago/);
const secondRowHtml = rows[1].innerHTML;
assert.match(secondRowHtml, /bravo\.mesh/);
assert.match(secondRowHtml, /<em>—<\/em>/); // no contact link
assert.match(secondRowHtml, /2\.0\.0/);
assert.match(secondRowHtml, />2</);
assert.match(secondRowHtml, /d ago/);
assert.deepEqual(mapFitBoundsCalls[0][0], [[10.12345, -20.98765]]);
assert.equal(circleMarkerCalls[0].options.fillColor, roleColors.CLIENT_HIDDEN);
} catch (error) {
console.error('federation sorting test error', error);
throw error;
} finally {
cleanup();
}
});
test('federation table sorting, contact rendering, and legend creation', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
tableEl.appendChild(tbodyEl);
const headerNameTh = createElement('th');
const headerName = createElement('span');
headerName.classList.add('sort-header');
headerName.dataset.sortKey = 'name';
headerName.dataset.sortLabel = 'Name';
headerNameTh.appendChild(headerName);
const headerDomainTh = createElement('th');
const headerDomain = createElement('span');
headerDomain.classList.add('sort-header');
headerDomain.dataset.sortKey = 'domain';
headerDomain.dataset.sortLabel = 'Domain';
headerDomainTh.appendChild(headerDomain);
const ths = [headerNameTh, headerDomainTh];
const headers = [headerName, headerDomain];
const headerHandlers = new Map();
headers.forEach(header => {
header.addEventListener = (event, handler) => {
const existing = headerHandlers.get(header) || {};
existing[event] = handler;
headerHandlers.set(header, existing);
};
header.closest = () => ths.find(th => th.childNodes.includes(header));
header.querySelector = selector => {
if (selector === '.sort-indicator') {
const span = createElement('span');
span.classList.add('sort-indicator');
return span;
}
return null;
};
});
tableEl.querySelectorAll = selector => {
if (selector === 'thead .sort-header[data-sort-key]') return headers;
if (selector === 'thead th') return ths;
return [];
};
const configPayload = {
mapCenter: { lat: 0, lon: 0 },
mapZoom: 3,
tileFilters: { light: 'none', dark: 'invert(1)' }
};
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify(configPayload));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
const legendContainers = [];
const mapSetViewCalls = [];
const mapFitBoundsCalls = [];
const circleMarkerCalls = [];
const DomUtil = {
create(tag, className, parent) {
const el = {
tagName: tag,
className,
children: [],
style: {},
textContent: '',
setAttribute() {},
appendChild(child) {
this.children.push(child);
return child;
},
};
if (parent && parent.appendChild) parent.appendChild(el);
return el;
}
};
const controlStub = () => {
const ctrl = {
onAdd: null,
container: null,
addTo(map) {
this.container = this.onAdd ? this.onAdd(map) : null;
legendContainers.push(this.container);
return this;
},
getContainer() {
return this.container;
}
};
return ctrl;
};
const markersLayer = {
layers: [],
addLayer(marker) {
this.layers.push(marker);
return marker;
},
addTo() {
return this;
}
};
const mapStub = {
addedControls: [],
setView(...args) {
mapSetViewCalls.push(args);
},
on() {},
fitBounds(...args) {
mapFitBoundsCalls.push(args);
},
addLayer(layer) {
this.addedControls.push(layer);
return layer;
}
};
const leafletStub = {
map() {
return mapStub;
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return markersLayer;
},
circleMarker(latlng, options) {
circleMarkerCalls.push({ latlng, options });
return {
bindPopup() {
return this;
},
addTo() {
return this;
}
};
},
control: controlStub,
DomUtil
};
const now = Math.floor(Date.now() / 1000);
const fetchImpl = async () => ({
ok: true,
json: async () => [
{
domain: 'c.mesh',
name: 'Charlie',
contactLink: 'https://charlie.example\nmatrix:#c:mesh',
version: '3.0.0',
latitude: 1,
longitude: 1,
lastUpdateTime: now - 10,
nodesCount: 0
},
{
domain: 'b.mesh',
contactLink: '',
version: '2.0.0',
latitude: 2,
longitude: 2,
lastUpdateTime: now - 60,
nodesCount: 650
},
{
domain: 'a.mesh',
name: 'Alpha',
contactLink: 'mailto:alpha@mesh',
version: '1.0.0',
latitude: 3,
longitude: 3,
lastUpdateTime: now - 30,
nodesCount: 5
}
]
});
try {
await initializeFederationPage({ config: configPayload, fetchImpl, leaflet: leafletStub });
const rows = tbodyEl.childNodes.map(node => String(node.childNodes[0]));
assert.match(rows[0], /c\.mesh/);
assert.match(rows[0], /0</);
assert.match(rows[0], /https:\/\/charlie\.example/);
assert.match(rows[0], /matrix:#c:mesh/);
assert.match(rows[1], /a\.mesh/);
assert.match(rows[2], /b\.mesh/);
const nameHandlers = headerHandlers.get(headerName);
nameHandlers.click();
const afterNameSort = tbodyEl.childNodes.map(node => String(node.childNodes[0]));
assert.match(afterNameSort[0], /a\.mesh/);
assert.match(afterNameSort[1], /c\.mesh/);
assert.match(afterNameSort[2], /b\.mesh/);
nameHandlers.click();
const descSort = tbodyEl.childNodes.map(node => String(node.childNodes[0]));
assert.match(descSort[0], /c\.mesh/);
assert.match(descSort[1], /a\.mesh/);
assert.match(descSort[2], /b\.mesh/);
assert.equal(headerName.closest().attributes.get('aria-sort'), 'descending');
assert.equal(circleMarkerCalls[0].options.fillColor, roleColors.CLIENT_HIDDEN);
assert.equal(circleMarkerCalls[1].options.fillColor, roleColors.REPEATER);
assert.deepEqual(mapSetViewCalls[0], [[0, 0], 3]);
assert.equal(mapFitBoundsCalls[0][0].length, 3);
assert.equal(legendContainers.length, 1);
const legend = legendContainers[0];
assert.ok(legend.className.includes('legend'));
const legendHeader = legend.children.find(child => child.className === 'legend-header');
const legendTitle = legendHeader && Array.isArray(legendHeader.children)
? legendHeader.children.find(child => child.className === 'legend-title')
: null;
assert.ok(legendTitle);
assert.equal(legendTitle.textContent, 'Active nodes');
} finally {
cleanup();
}
});
test('federation page tolerates fetch failures', async () => {
const env = createDomEnvironment({ includeBody: true, bodyHasDarkClass: false });
const { document, createElement, registerElement, cleanup } = env;
const mapEl = createElement('div', 'map');
registerElement('map', mapEl);
const statusEl = createElement('div', 'status');
registerElement('status', statusEl);
const tableEl = createElement('table', 'instances');
const tbodyEl = createElement('tbody');
registerElement('instances', tableEl);
const configEl = createElement('div');
configEl.setAttribute('data-app-config', JSON.stringify({}));
document.querySelector = selector => {
if (selector === '[data-app-config]') return configEl;
if (selector === '#instances tbody') return tbodyEl;
return null;
};
const leafletStub = {
map() {
return {
setView() {},
on() {},
getPane() {
return null;
}
};
},
tileLayer() {
return {
addTo() {
return this;
},
getContainer() {
return null;
},
on() {}
};
},
layerGroup() {
return { addLayer() {}, addTo() { return this; } };
},
circleMarker() {
return { bindPopup() { return this; } };
}
};
const fetchImpl = async () => {
throw new Error('boom');
};
await initializeFederationPage({ config: {}, fetchImpl, leaflet: leafletStub });
cleanup();
});
@@ -62,6 +62,16 @@ test('normalizeNodeCollection applies canonical forms to all nodes', () => {
assert.equal(nodes[1].air_util_tx, 5.5);
});
test('normalizeNodeSnapshot maps numeric roles to canonical identifiers', () => {
const roleNode = { role: '12', node_id: '!role' };
const numberRoleNode = { role: 12, nodeId: '!number-role' };
normalizeNodeCollection([roleNode, numberRoleNode]);
assert.equal(roleNode.role, 'CLIENT_BASE');
assert.equal(numberRoleNode.role, 'CLIENT_BASE');
});
test('normaliser helpers coerce primitive values consistently', () => {
assert.equal(normalizeNumber('42.1'), 42.1);
assert.equal(normalizeNumber('not-a-number'), null);
@@ -19,8 +19,13 @@ import assert from 'node:assert/strict';
import { buildTraceSegments, __testUtils } from '../trace-paths.js';
const { coerceFiniteNumber, findNode, resolveNodeCoordinates } = __testUtils;
const { buildNodeIndex } = __testUtils;
const {
coerceFiniteNumber,
findNode,
resolveNodeCoordinates,
canonicalNodeIdFromNumeric,
buildNodeIndex
} = __testUtils;
test('buildTraceSegments connects source, hops, and destination when coordinates exist', () => {
const traces = [
@@ -43,6 +48,29 @@ test('buildTraceSegments connects source, hops, and destination when coordinates
assert.equal(segments[0].color, 'color:ROUTER');
assert.equal(segments[1].color, 'color:CLIENT');
assert.equal(segments[0].rxTime, 1700);
assert.deepEqual(
segments[0].pathNodes.map(node => node.node_id),
['2658361180', '19088743', '4242424242']
);
});
test('buildTraceSegments links traces to canonical node IDs when numeric references are provided', () => {
const traces = [
{ id: 9_010, src: 0xbead_f00d, hops: [0xcafe_babe], dest: 0xfeed_c0de, rx_time: 1900 },
];
const nodes = [
{ node_id: '!beadf00d', latitude: 0, longitude: 0, role: 'ROUTER' },
{ node_id: '!cafebabe', latitude: 1, longitude: 1, role: 'CLIENT' },
{ node_id: '!feedc0de', latitude: 2, longitude: 2, role: 'CLIENT' },
];
const segments = buildTraceSegments(traces, nodes, { colorForNode: () => '#abcdef' });
assert.equal(segments.length, 2);
assert.deepEqual(segments[0].latlngs, [[0, 0], [1, 1]]);
assert.deepEqual(segments[1].latlngs, [[1, 1], [2, 2]]);
assert.equal(segments[0].color, '#abcdef');
assert.equal(segments[1].color, '#abcdef');
});
test('buildTraceSegments drops paths through hops without locations', () => {
@@ -98,13 +126,24 @@ test('helper utilities coerce values and locate nodes', () => {
assert.equal(coerceFiniteNumber(null), null);
assert.equal(coerceFiniteNumber(' '), null);
assert.equal(coerceFiniteNumber('7'), 7);
assert.equal(coerceFiniteNumber('!beadf00d'), 0xbeadf00d);
assert.equal(coerceFiniteNumber('0x10'), 16);
const byId = new Map([['!id', { node_id: '!id', latitude: 1, longitude: 2 }]]);
const byNum = new Map([[99, { node_id: '!other', latitude: 0, longitude: 0 }]]);
const byId = new Map([
['!id', { node_id: '!id', latitude: 1, longitude: 2 }],
['!beadf00d', { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
]);
const byNum = new Map([
[99, { node_id: '!other', latitude: 0, longitude: 0 }],
[0xbeadf00d, { node_id: '!beadf00d', latitude: 3, longitude: 4 }]
]);
assert.equal(findNode(byId, byNum, '!id').node_id, '!id');
assert.equal(findNode(byId, byNum, 99).node_id, '!other');
assert.equal(findNode(byId, new Map(), 0xbeadf00d).node_id, '!beadf00d');
assert.equal(findNode(byId, byNum, 100), null);
assert.equal(canonicalNodeIdFromNumeric(0xbeadf00d), '!beadf00d');
const coords = resolveNodeCoordinates({ latitude: 5, longitude: 6, distance_km: 10 }, { limitDistance: true, maxDistanceKm: 15 });
assert.deepEqual(coords, [5, 6]);
const outOfRange = resolveNodeCoordinates({ latitude: 0, longitude: 0, distance_km: 20 }, { limitDistance: true, maxDistanceKm: 15 });
+118 -11
View File
@@ -30,7 +30,8 @@ export const MAX_CHANNEL_INDEX = 9;
* NODE_INFO: 'node-info',
* TELEMETRY: 'telemetry',
* POSITION: 'position',
* NEIGHBOR: 'neighbor'
* NEIGHBOR: 'neighbor',
* TRACE: 'trace'
* }}
*/
export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
@@ -39,6 +40,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({
TELEMETRY: 'telemetry',
POSITION: 'position',
NEIGHBOR: 'neighbor',
TRACE: 'trace',
MESSAGE_ENCRYPTED: 'message-encrypted'
});
@@ -70,6 +72,7 @@ function resolveSnapshotList(entry) {
* telemetry?: Array<Object>,
* positions?: Array<Object>,
* neighbors?: Array<Object>,
* traces?: Array<Object>,
* messages?: Array<Object>,
* logOnlyMessages?: Array<Object>,
* nowSeconds: number,
@@ -87,6 +90,7 @@ export function buildChatTabModel({
telemetry = [],
positions = [],
neighbors = [],
traces = [],
messages = [],
logOnlyMessages = [],
nowSeconds,
@@ -156,6 +160,34 @@ export function buildChatTabModel({
}
}
for (const trace of traces || []) {
if (!trace) continue;
const ts = resolveTimestampSeconds(trace.rx_time ?? trace.rxTime, trace.rx_iso ?? trace.rxIso);
if (ts == null || ts < cutoff) continue;
const path = buildTracePath(trace);
if (path.length < 2) continue;
const firstHop = path[0] || {};
const traceLabels = path
.map(hop => {
if (!hop || typeof hop !== 'object') return null;
const candidates = [hop.id, hop.raw];
if (Number.isFinite(hop.num)) {
candidates.push(String(hop.num));
}
return candidates.find(val => val != null && String(val).trim().length > 0) ?? null;
})
.filter(value => value != null && value !== '');
logEntries.push({
ts,
type: CHAT_LOG_ENTRY_TYPES.TRACE,
trace,
tracePath: path,
traceLabels,
nodeId: firstHop.id ?? null,
nodeNum: firstHop.num ?? null
});
}
const encryptedLogEntries = [];
const encryptedLogKeys = new Set();
@@ -345,10 +377,59 @@ function pickFirstPropertyValue(source, keys) {
* @param {*} value Arbitrary payload candidate.
* @returns {?string} Canonical node identifier.
*/
function coerceFiniteNumber(value) {
if (value == null) return null;
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function canonicalNodeIdFromNumeric(ref) {
if (!Number.isFinite(ref)) return null;
const unsigned = ref >>> 0;
const hex = unsigned.toString(16).padStart(8, '0');
return `!${hex}`;
}
function normaliseNodeId(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_id ?? value.nodeId ?? null;
return typeof raw === 'string' && raw.trim().length ? raw.trim() : null;
if (value == null) return null;
if (typeof value === 'number') {
return canonicalNodeIdFromNumeric(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const canonicalFromNumeric = canonicalNodeIdFromNumeric(coerceFiniteNumber(trimmed));
return canonicalFromNumeric ?? trimmed;
}
if (typeof value !== 'object') return null;
const rawId = value.node_id ?? value.nodeId ?? null;
if (rawId != null) {
const canonical = normaliseNodeId(rawId);
if (canonical) return canonical;
}
const numericRef = value.node_num ?? value.nodeNum ?? value.num;
const numericId = canonicalNodeIdFromNumeric(coerceFiniteNumber(numericRef));
if (numericId) return numericId;
return null;
}
/**
@@ -366,6 +447,29 @@ function normaliseNeighborId(value) {
return null;
}
/**
* Build an ordered trace path of node identifiers and numeric references.
*
* @param {Object} trace Trace payload.
* @returns {Array<{id: ?string, num: ?number, raw: *}>} Ordered hop descriptors.
*/
function buildTracePath(trace) {
const path = [];
const append = value => {
if (value == null || value === '') return;
const id = normaliseNodeId(value);
const num = normaliseNodeNum({ num: value });
path.push({ id, num, raw: value });
};
append(trace.src ?? trace.source ?? trace.from);
const hops = Array.isArray(trace.hops) ? trace.hops : [];
for (const hop of hops) {
append(hop);
}
append(trace.dest ?? trace.destination ?? trace.to);
return path;
}
/**
* Extract a finite node number from a payload when available.
*
@@ -373,14 +477,17 @@ function normaliseNeighborId(value) {
* @returns {?number} Canonical numeric identifier.
*/
function normaliseNodeNum(value) {
if (!value || typeof value !== 'object') return null;
const raw = value.node_num ?? value.nodeNum ?? value.num;
if (raw == null || raw === '') return null;
if (typeof raw === 'number') {
return Number.isFinite(raw) ? raw : null;
if (Number.isFinite(value)) {
return Math.trunc(value);
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
const fromObject = value && typeof value === 'object'
? coerceFiniteNumber(value.node_num ?? value.nodeNum ?? value.num)
: null;
if (fromObject != null) {
return Math.trunc(fromObject);
}
const parsed = coerceFiniteNumber(value);
return parsed != null ? Math.trunc(parsed) : null;
}
/**
+1
View File
@@ -110,6 +110,7 @@ export function chatLogEntryMatchesQuery(entry, query) {
candidates.push(...collectSearchValues(entry.position));
candidates.push(...collectSearchValues(entry.neighbor));
candidates.push(...collectSearchValues(entry.neighborNode));
candidates.push(...(Array.isArray(entry.traceLabels) ? entry.traceLabels : []));
if (entry.nodeId) candidates.push(entry.nodeId);
if (entry.nodeNum != null && entry.nodeNum !== '') candidates.push(entry.nodeNum);
if (entry.neighborId) candidates.push(entry.neighborId);
+565
View File
@@ -0,0 +1,565 @@
/*
* 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 { readAppConfig } from './config.js';
import { mergeConfig } from './settings.js';
import { roleColors } from './role-helpers.js';
/**
* Escape HTML special characters to prevent XSS.
*
* @param {string} str Raw string to escape.
* @returns {string} Escaped string safe for HTML insertion.
*/
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Format a coordinate value to fixed decimal places.
*
* @param {number|null|undefined} v Coordinate value.
* @param {number} d Decimal places (default 5).
* @returns {string} Formatted coordinate or empty string.
*/
function fmtCoords(v, d = 5) {
if (v == null || v === '') return '';
const n = Number(v);
if (!Number.isFinite(n)) return '';
return n.toFixed(d);
}
/**
* Convert a Unix timestamp to a human-readable relative time string.
*
* @param {number|null|undefined} unixSec Unix timestamp in seconds.
* @param {number} nowSec Current timestamp in seconds.
* @returns {string} Relative time string or empty string.
*/
function timeAgo(unixSec, nowSec = Date.now() / 1000) {
if (unixSec == null || unixSec === '') return '';
const ts = Number(unixSec);
if (!Number.isFinite(ts) || ts <= 0) return '';
const diff = Math.max(0, Math.floor(nowSec - ts));
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
/**
* Build a navigable URL for an instance domain.
*
* @param {string} domain Instance domain.
* @returns {string|null} Navigable URL or null.
*/
function buildInstanceUrl(domain) {
if (typeof domain !== 'string' || !domain.trim()) return null;
const trimmed = domain.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
const NODE_COUNT_COLOR_STOPS = [
{ limit: 100, color: roleColors.CLIENT_HIDDEN },
{ limit: 200, color: roleColors.SENSOR },
{ limit: 300, color: roleColors.TRACKER },
{ limit: 400, color: roleColors.CLIENT_MUTE },
{ limit: 500, color: roleColors.CLIENT },
{ limit: 600, color: roleColors.CLIENT_BASE },
{ limit: 700, color: roleColors.REPEATER },
{ limit: 800, color: roleColors.ROUTER_LATE },
{ limit: 900, color: roleColors.ROUTER }
];
const DEFAULT_INSTANCE_COLOR = roleColors.LOST_AND_FOUND || '#3388ff';
/**
* Determine the marker colour for an instance based on its active node count.
*
* @param {*} count Raw node count value from the API.
* @returns {string} CSS colour string.
*/
function colorForNodeCount(count) {
const numeric = Number(count);
if (!Number.isFinite(numeric) || numeric < 0) return DEFAULT_INSTANCE_COLOR;
const stop = NODE_COUNT_COLOR_STOPS.find(entry => numeric < entry.limit);
return stop && stop.color ? stop.color : DEFAULT_INSTANCE_COLOR;
}
/**
* Render arbitrary contact text while hyperlinking recognised URL-like segments.
*
* @param {*} contact Raw contact value from the API.
* @returns {string} HTML markup safe for insertion.
*/
function renderContactHtml(contact) {
if (typeof contact !== 'string') return '';
const trimmed = contact.trim();
if (!trimmed) return '';
const urlPattern = /(https?:\/\/[^\s]+|mailto:[^\s]+|matrix:[^\s]+)/gi;
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlPattern.exec(trimmed)) !== null) {
const textBefore = trimmed.slice(lastIndex, match.index);
if (textBefore) {
parts.push(escapeHtml(textBefore));
}
const url = match[0];
const safeUrl = escapeHtml(url);
parts.push(`<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a>`);
lastIndex = match.index + url.length;
}
const trailing = trimmed.slice(lastIndex);
if (trailing) {
parts.push(escapeHtml(trailing));
}
const html = parts.join('');
return html.replace(/\r?\n/g, '<br>');
}
/**
* Convert a value into a finite number or null when invalid.
*
* @param {*} value Raw value to convert.
* @returns {number|null} Finite number or null.
*/
function toFiniteNumber(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
/**
* Compare two string-like values ignoring case.
*
* @param {*} a Left-hand operand.
* @param {*} b Right-hand operand.
* @returns {number} Comparator result.
*/
function compareString(a, b) {
const left = typeof a === 'string' ? a.toLowerCase() : String(a ?? '').toLowerCase();
const right = typeof b === 'string' ? b.toLowerCase() : String(b ?? '').toLowerCase();
return left.localeCompare(right);
}
/**
* Compare two numeric values.
*
* @param {*} a Left-hand operand.
* @param {*} b Right-hand operand.
* @returns {number} Comparator result.
*/
function compareNumber(a, b) {
const left = toFiniteNumber(a);
const right = toFiniteNumber(b);
if (left == null && right == null) return 0;
if (left == null) return 1;
if (right == null) return -1;
if (left === right) return 0;
return left < right ? -1 : 1;
}
/**
* Determine whether a string-like value is present.
*
* @param {*} value Candidate value.
* @returns {boolean} true when present.
*/
function hasStringValue(value) {
if (value == null) return false;
if (typeof value === 'string') return value.trim() !== '';
return String(value).trim() !== '';
}
/**
* Determine whether a numeric value is present.
*
* @param {*} value Candidate value.
* @returns {boolean} true when present.
*/
function hasNumberValue(value) {
return toFiniteNumber(value) != null;
}
const TILE_LAYER_URL = 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png';
/**
* Initialize the federation page by fetching instances, rendering the map,
* and populating the table.
*
* @param {{
* config?: object,
* fetchImpl?: typeof fetch,
* leaflet?: typeof L
* }} [options] Optional overrides for testing.
* @returns {Promise<void>}
*/
export async function initializeFederationPage(options = {}) {
const rawConfig = options.config || readAppConfig();
const config = mergeConfig(rawConfig);
const fetchImpl = options.fetchImpl || fetch;
const leaflet = options.leaflet || (typeof window !== 'undefined' ? window.L : null);
const mapContainer = document.getElementById('map');
const tableEl = document.getElementById('instances');
const tableBody = document.querySelector('#instances tbody');
const statusEl = document.getElementById('status');
const sortHeaders = tableEl
? Array.from(tableEl.querySelectorAll('thead .sort-header[data-sort-key]'))
: [];
const hasLeaflet =
typeof leaflet === 'object' &&
leaflet &&
typeof leaflet.map === 'function' &&
typeof leaflet.tileLayer === 'function';
let map = null;
let markersLayer = null;
let tileLayer = null;
const tableSorters = {
name: { getValue: inst => inst.name ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
domain: { getValue: inst => inst.domain ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
contact: { getValue: inst => inst.contactLink ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
version: { getValue: inst => inst.version ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
channel: { getValue: inst => inst.channel ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
frequency: { getValue: inst => inst.frequency ?? '', compare: compareString, hasValue: hasStringValue, defaultDirection: 'asc' },
nodesCount: {
getValue: inst => toFiniteNumber(inst.nodesCount ?? inst.nodes_count),
compare: compareNumber,
hasValue: hasNumberValue,
defaultDirection: 'desc'
},
latitude: { getValue: inst => toFiniteNumber(inst.latitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
longitude: { getValue: inst => toFiniteNumber(inst.longitude), compare: compareNumber, hasValue: hasNumberValue, defaultDirection: 'asc' },
lastUpdateTime: {
getValue: inst => toFiniteNumber(inst.lastUpdateTime),
compare: compareNumber,
hasValue: hasNumberValue,
defaultDirection: 'desc'
}
};
let sortState = {
key: 'lastUpdateTime',
direction: tableSorters.lastUpdateTime ? tableSorters.lastUpdateTime.defaultDirection : 'desc'
};
/**
* Sort instances using the active sort configuration.
*
* @param {Array<Object>} data Instance rows.
* @returns {Array<Object>} sorted rows.
*/
const sortInstancesData = data => {
const sorter = tableSorters[sortState.key];
if (!sorter) return Array.isArray(data) ? [...data] : [];
const dir = sortState.direction === 'asc' ? 1 : -1;
return [...(data || [])].sort((a, b) => {
const aVal = sorter.getValue(a);
const bVal = sorter.getValue(b);
const aHas = sorter.hasValue ? sorter.hasValue(aVal) : hasStringValue(aVal);
const bHas = sorter.hasValue ? sorter.hasValue(bVal) : hasStringValue(bVal);
if (aHas && bHas) {
return sorter.compare(aVal, bVal) * dir;
}
if (aHas) return -1;
if (bHas) return 1;
return 0;
});
};
/**
* Update the visual sort indicators for the active column.
*
* @returns {void}
*/
const syncSortIndicators = () => {
if (!tableEl || !sortHeaders.length) return;
tableEl.querySelectorAll('thead th').forEach(th => th.removeAttribute('aria-sort'));
sortHeaders.forEach(header => {
header.removeAttribute('data-sort-active');
const indicator = header.querySelector('.sort-indicator');
if (indicator) indicator.textContent = '';
});
const active = sortHeaders.find(header => header.dataset.sortKey === sortState.key);
if (!active) return;
const indicator = active.querySelector('.sort-indicator');
if (indicator) indicator.textContent = sortState.direction === 'asc' ? '▲' : '▼';
active.setAttribute('data-sort-active', 'true');
const th = active.closest('th');
if (th) {
th.setAttribute('aria-sort', sortState.direction === 'asc' ? 'ascending' : 'descending');
}
};
/**
* Render the instances table body with sorting applied.
*
* @param {Array<Object>} data Instance rows.
* @param {number} nowSec Reference timestamp for relative time rendering.
* @returns {void}
*/
const renderTableRows = (data, nowSec) => {
if (!tableBody) return;
const frag = document.createDocumentFragment();
const sorted = sortInstancesData(data);
for (const instance of sorted) {
const tr = document.createElement('tr');
const url = buildInstanceUrl(instance.domain);
const nameHtml = instance.name ? escapeHtml(instance.name) : '<em>—</em>';
const domainHtml = url
? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(instance.domain || '')}</a>`
: escapeHtml(instance.domain || '');
const contactHtml = renderContactHtml(instance.contactLink);
const nodesCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const nodesCountText = nodesCountValue == null ? '<em>—</em>' : escapeHtml(String(nodesCountValue));
tr.innerHTML = `
<td class="instances-col instances-col--name">${nameHtml}</td>
<td class="instances-col instances-col--domain mono">${domainHtml}</td>
<td class="instances-col instances-col--contact">${contactHtml || '<em>—</em>'}</td>
<td class="instances-col instances-col--version mono">${escapeHtml(instance.version || '')}</td>
<td class="instances-col instances-col--channel">${escapeHtml(instance.channel || '')}</td>
<td class="instances-col instances-col--frequency">${escapeHtml(instance.frequency || '')}</td>
<td class="instances-col instances-col--nodes mono">${nodesCountText}</td>
<td class="instances-col instances-col--latitude mono">${fmtCoords(instance.latitude)}</td>
<td class="instances-col instances-col--longitude mono">${fmtCoords(instance.longitude)}</td>
<td class="instances-col instances-col--last-update mono">${timeAgo(instance.lastUpdateTime, nowSec)}</td>
`;
frag.appendChild(tr);
}
tableBody.replaceChildren(frag);
syncSortIndicators();
};
/**
* Wire up click and keyboard handlers for sortable headers.
*
* @param {Function} rerender Callback to refresh the table.
* @returns {void}
*/
const attachSortHandlers = rerender => {
if (!sortHeaders.length) return;
const applySortKey = key => {
if (!key) return;
if (sortState.key === key) {
sortState = { key, direction: sortState.direction === 'asc' ? 'desc' : 'asc' };
} else {
const defaultDir = tableSorters[key]?.defaultDirection || 'asc';
sortState = { key, direction: defaultDir };
}
rerender();
};
sortHeaders.forEach(header => {
const key = header.dataset.sortKey;
header.addEventListener('click', () => applySortKey(key));
header.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
applySortKey(key);
}
});
});
};
/**
* Resolve the active theme based on the DOM state.
*
* @returns {'dark' | 'light'}
*/
const resolveTheme = () => {
if (document.body && document.body.classList.contains('dark')) return 'dark';
const htmlTheme = document.documentElement?.getAttribute('data-theme');
if (htmlTheme === 'dark' || htmlTheme === 'light') return htmlTheme;
return 'dark';
};
/**
* Apply the configured CSS filter to the active tile container.
*
* @returns {void}
*/
const applyTileFilter = () => {
if (!tileLayer) return;
const theme = resolveTheme();
const filterValue = theme === 'dark' ? config.tileFilters.dark : config.tileFilters.light;
const container =
typeof tileLayer.getContainer === 'function' ? tileLayer.getContainer() : null;
if (container && container.style) {
container.style.filter = filterValue;
container.style.webkitFilter = filterValue;
}
const tilePane = map && typeof map.getPane === 'function' ? map.getPane('tilePane') : null;
if (tilePane && tilePane.style) {
tilePane.style.filter = filterValue;
tilePane.style.webkitFilter = filterValue;
}
const tileNodes = [];
if (container && typeof container.querySelectorAll === 'function') {
tileNodes.push(...container.querySelectorAll('.leaflet-tile'));
}
if (tilePane && typeof tilePane.querySelectorAll === 'function') {
tileNodes.push(...tilePane.querySelectorAll('.leaflet-tile'));
}
tileNodes.forEach(tile => {
if (tile && tile.style) {
tile.style.filter = filterValue;
tile.style.webkitFilter = filterValue;
}
});
};
// Initialize the map if Leaflet is available
if (hasLeaflet && mapContainer) {
const initialZoom = Number.isFinite(config.mapZoom) ? config.mapZoom : 5;
map = leaflet.map(mapContainer, { worldCopyJump: true, attributionControl: false });
map.setView([config.mapCenter.lat, config.mapCenter.lon], initialZoom);
tileLayer = leaflet
.tileLayer(TILE_LAYER_URL, {
maxZoom: 19,
className: 'map-tiles',
crossOrigin: 'anonymous'
})
.addTo(map);
tileLayer.on?.('load', applyTileFilter);
applyTileFilter();
window.addEventListener('themechange', applyTileFilter);
markersLayer = leaflet.layerGroup().addTo(map);
}
// Fetch instances data
let instances = [];
try {
const response = await fetchImpl('/api/instances', {
headers: { Accept: 'application/json' },
credentials: 'omit'
});
if (response.ok) {
instances = await response.json();
}
} catch (err) {
console.warn('Failed to fetch federation instances', err);
}
if (statusEl) {
statusEl.textContent = `${instances.length} instances`;
statusEl.classList.remove('pill--loading');
}
const nowSec = Date.now() / 1000;
// Render map markers
if (map && markersLayer && hasLeaflet && Array.isArray(instances)) {
const bounds = [];
const canRenderLegend =
typeof leaflet.control === 'function' && leaflet.DomUtil && typeof leaflet.DomUtil.create === 'function';
if (canRenderLegend) {
const legendStops = NODE_COUNT_COLOR_STOPS.map((stop, index) => {
const lower = index === 0 ? 0 : NODE_COUNT_COLOR_STOPS[index - 1].limit;
const upper = stop.limit - 1;
const label = index === 0 ? `< ${stop.limit} nodes` : `${lower}-${upper} nodes`;
return { color: stop.color || DEFAULT_INSTANCE_COLOR, label };
});
const lastLimit = NODE_COUNT_COLOR_STOPS[NODE_COUNT_COLOR_STOPS.length - 1]?.limit || 900;
legendStops.push({ color: DEFAULT_INSTANCE_COLOR, label: `${lastLimit} nodes` });
const legend = leaflet.control({ position: 'bottomright' });
legend.onAdd = function onAdd() {
const container = leaflet.DomUtil.create('div', 'legend legend--instances');
container.setAttribute('aria-label', 'Active nodes legend');
const header = leaflet.DomUtil.create('div', 'legend-header', container);
const title = leaflet.DomUtil.create('span', 'legend-title', header);
title.textContent = 'Active nodes';
const items = leaflet.DomUtil.create('div', 'legend-items', container);
legendStops.forEach(stop => {
const item = leaflet.DomUtil.create('div', 'legend-item', items);
item.setAttribute('aria-hidden', 'true');
const swatch = leaflet.DomUtil.create('span', 'legend-swatch', item);
swatch.style.background = stop.color;
const label = leaflet.DomUtil.create('span', 'legend-label', item);
label.textContent = stop.label;
});
return container;
};
legend.addTo(map);
}
for (const instance of instances) {
const lat = Number(instance.latitude);
const lon = Number(instance.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
bounds.push([lat, lon]);
const name = instance.name || instance.domain || 'Unknown';
const url = buildInstanceUrl(instance.domain);
const nodeCountValue = toFiniteNumber(instance.nodesCount ?? instance.nodes_count);
const popupLines = [
url
? `<strong><a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(name)}</a></strong>`
: `<strong>${escapeHtml(name)}</strong>`,
`<span class="mono">${escapeHtml(instance.domain || '')}</span>`,
instance.channel ? `Channel: ${escapeHtml(instance.channel)}` : '',
instance.frequency ? `Frequency: ${escapeHtml(instance.frequency)}` : '',
instance.version ? `Version: ${escapeHtml(instance.version)}` : '',
nodeCountValue != null ? `Active nodes (24h): ${escapeHtml(String(nodeCountValue))}` : ''
].filter(Boolean);
const marker = leaflet.circleMarker([lat, lon], {
radius: 9,
fillColor: colorForNodeCount(nodeCountValue),
color: '#000',
weight: 1,
opacity: 0.8,
fillOpacity: 0.75
});
marker.bindPopup(popupLines.join('<br>'));
markersLayer.addLayer(marker);
}
if (bounds.length > 0 && typeof map.fitBounds === 'function') {
try {
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 10 });
} catch (err) {
console.warn('Failed to fit federation map bounds', err);
}
}
}
// Render table
if (tableBody && Array.isArray(instances)) {
attachSortHandlers(() => renderTableRows(instances, nowSec));
renderTableRows(instances, nowSec);
}
}
+308 -8
View File
@@ -190,6 +190,7 @@ export function initializeApp(config) {
});
const NODE_LIMIT = 1000;
const TRACE_LIMIT = 200;
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -433,6 +434,7 @@ export function initializeApp(config) {
const mapPanel = document.getElementById('mapPanel');
const mapFullscreenToggle = document.getElementById('mapFullscreenToggle');
const fullscreenContainer = mapPanel || mapContainer;
const isFederationView = bodyClassList ? bodyClassList.contains('view-federation') : false;
let mapStatusEl = null;
let map = null;
let mapCenterLatLng = null;
@@ -451,8 +453,11 @@ export function initializeApp(config) {
const AUTO_FIT_PADDING_PX = 12;
const MAX_INITIAL_ZOOM = 13;
let neighborLinesLayer = null;
let traceLinesLayer = null;
let neighborLinesVisible = true;
let traceLinesVisible = true;
let neighborLinesToggleButton = null;
let traceLinesToggleButton = null;
let markersLayer = null;
let tileDomObserver = null;
const fullscreenChangeEvents = [
@@ -1170,7 +1175,9 @@ export function initializeApp(config) {
applyFiltersToAllTiles();
}
if (hasLeaflet && mapContainer) {
const mapAlreadyInitialized = mapContainer && mapContainer._leaflet_id;
if (hasLeaflet && mapContainer && !isFederationView && !mapAlreadyInitialized) {
map = L.map(mapContainer, { worldCopyJump: true, attributionControl: false });
showMapStatus('Loading map tiles…');
tiles = L.tileLayer(TILE_LAYER_URL, {
@@ -1241,12 +1248,13 @@ export function initializeApp(config) {
});
neighborLinesLayer = L.layerGroup().addTo(map);
traceLinesLayer = L.layerGroup().addTo(map);
markersLayer = L.layerGroup().addTo(map);
if (typeof navigator !== 'undefined' && navigator && navigator.onLine === false) {
activateOfflineTiles('Offline mode detected. Using placeholder basemap.');
}
} else if (mapContainer) {
} else if (mapContainer && !isFederationView) {
setMapPlaceholder('Leaflet assets are unavailable. Data will continue to refresh without a live map.');
}
@@ -1333,6 +1341,38 @@ export function initializeApp(config) {
updateNeighborLinesToggleState();
}
/**
* Synchronise the traceroute line toggle button with the active state.
*
* @returns {void}
*/
function updateTraceLinesToggleState() {
if (!traceLinesToggleButton) return;
const label = traceLinesVisible ? 'Hide trace lines' : 'Show trace lines';
traceLinesToggleButton.textContent = label;
traceLinesToggleButton.setAttribute('aria-pressed', traceLinesVisible ? 'true' : 'false');
traceLinesToggleButton.setAttribute('aria-label', label);
}
/**
* Toggle the Leaflet layer that renders traceroute connections.
*
* @param {boolean} visible Whether to show traceroute paths.
* @returns {void}
*/
function setTraceLinesVisibility(visible) {
traceLinesVisible = Boolean(visible);
if (traceLinesLayer && map) {
const hasLayer = map.hasLayer(traceLinesLayer);
if (traceLinesVisible && !hasLayer) {
traceLinesLayer.addTo(map);
} else if (!traceLinesVisible && hasLayer) {
map.removeLayer(traceLinesLayer);
}
}
updateTraceLinesToggleState();
}
/**
* Refresh the legend buttons to reflect the active role filters.
*
@@ -1430,6 +1470,15 @@ export function initializeApp(config) {
});
updateNeighborLinesToggleState();
traceLinesToggleButton = L.DomUtil.create('button', 'legend-item legend-toggle-traces', toggle);
traceLinesToggleButton.type = 'button';
traceLinesToggleButton.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
setTraceLinesVisibility(!traceLinesVisible);
});
updateTraceLinesToggleState();
const resetButton = L.DomUtil.create('button', 'legend-item legend-reset', toggle);
resetButton.type = 'button';
resetButton.textContent = 'Clear filters';
@@ -1761,6 +1810,37 @@ export function initializeApp(config) {
return value.replace(/[^a-zA-Z0-9_-]/g, chr => `\\${chr}`);
}
/**
* Parse a node identifier or numeric reference into a finite number.
*
* @param {*} ref Identifier or numeric reference.
* @returns {number|null} Parsed number or ``null``.
*/
function parseNodeNumericRef(ref) {
if (ref == null) return null;
if (typeof ref === 'number') {
return Number.isFinite(ref) ? ref : null;
}
if (typeof ref === 'string') {
const trimmed = ref.trim();
if (!trimmed) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const parsed = Number(ref);
return Number.isFinite(parsed) ? parsed : null;
}
/**
* Populate the ``nodesById`` index for quick lookups.
*
@@ -1778,9 +1858,13 @@ export function initializeApp(config) {
: (typeof node.nodeId === 'string' ? node.nodeId : null);
if (nodeIdRaw) {
nodesById.set(nodeIdRaw.trim(), node);
const numericFromId = parseNodeNumericRef(nodeIdRaw);
if (numericFromId != null && !nodesByNum.has(numericFromId)) {
nodesByNum.set(numericFromId, node);
}
}
const nodeNumRaw = node.num ?? node.node_num ?? node.nodeNum;
const nodeNum = typeof nodeNumRaw === 'number' ? nodeNumRaw : Number(nodeNumRaw);
const nodeNum = parseNodeNumericRef(nodeNumRaw);
if (Number.isFinite(nodeNum)) {
nodesByNum.set(nodeNum, node);
}
@@ -2397,6 +2481,8 @@ export function initializeApp(config) {
return createPositionChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.NEIGHBOR:
return createNeighborChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.TRACE:
return createTraceChatEntry(entry, context);
case CHAT_LOG_ENTRY_TYPES.MESSAGE_ENCRYPTED:
return entry?.message ? createMessageChatEntry(entry.message) : null;
default:
@@ -2443,6 +2529,138 @@ export function initializeApp(config) {
return div;
}
/**
* Convert a trace path into user-friendly labels using cached node metadata.
*
* @param {Array<{id: ?string, num: ?number, raw: *}>} tracePath Ordered hop references.
* @returns {Array<string>} Display labels for each hop.
*/
function formatTracePathLabels(tracePath) {
if (!Array.isArray(tracePath)) return [];
const labels = [];
for (const hop of tracePath) {
if (!hop || typeof hop !== 'object') continue;
const node = resolveNodeForHop(hop);
const fallbackId = hop.id ?? (Number.isFinite(hop.num) ? String(hop.num) : (hop.raw != null ? String(hop.raw) : ''));
const shortName = node ? normalizeNodeNameValue(node.short_name ?? node.shortName) : null;
const label = shortName || (node ? (getNodeDisplayNameForOverlay(node) || fallbackId) : fallbackId);
if (label) {
labels.push(String(label));
}
}
return labels;
}
function createTraceChatEntry(entry, context) {
if (!entry || !Array.isArray(entry.tracePath) || entry.tracePath.length < 2) {
return null;
}
const sourceHop = entry.tracePath[0] || null;
const sourceNode = resolveNodeForHop(sourceHop);
const labels = formatTracePathLabels(entry.tracePath);
const labelText = labels.length ? labels.join(', ') : 'Traceroute';
const labelSuffix = `: ${escapeHtml(labelText)}`;
return createAnnouncementEntry({
timestampSeconds: entry?.ts ?? null,
shortName: context.shortName,
longName: context.longName || context.nodeId || labels[0] || 'Traceroute',
role: context.role,
metadataSource: sourceNode || context.metadataSource,
nodeData: sourceNode || context.nodeData,
messageHtml: `${renderEmojiHtml('👣')} ${renderAnnouncementCopy('Caught trace', labelSuffix)}`
});
}
/**
* Build tooltip HTML showing styled short-name badges for a trace path.
*
* @param {Array<Object>} pathNodes Ordered node payloads along the trace.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
function buildTraceTooltipHtml(pathNodes) {
if (!Array.isArray(pathNodes) || pathNodes.length < 2) {
return '';
}
const parts = pathNodes
.map(node => {
if (!node || typeof node !== 'object') {
return null;
}
const short = normalizeNodeNameValue(node.short_name ?? node.shortName) || (typeof node.node_id === 'string' ? node.node_id : '');
const long = normalizeNodeNameValue(node.long_name ?? node.longName) || '';
return renderShortHtml(short, node.role, long, node);
})
.filter(Boolean);
if (!parts.length) return '';
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${parts.join(arrow)}</div>`;
}
/**
* Build tooltip HTML for a neighbor segment showing styled short-name badges.
*
* @param {{sourceNode?: Object, targetNode?: Object, sourceShortName?: string, targetShortName?: string, sourceRole?: string, targetRole?: string}} segment Neighbor segment descriptor.
* @returns {string} HTML fragment or ``''`` when unavailable.
*/
function buildNeighborTooltipHtml(segment) {
if (!segment) return '';
const sourceNode = segment.sourceNode || null;
const targetNode = segment.targetNode || null;
const sourceShort = normalizeNodeNameValue(
segment.sourceShortName ||
(sourceNode ? sourceNode.short_name ?? sourceNode.shortName : null) ||
(sourceNode && typeof sourceNode.node_id === 'string' ? sourceNode.node_id : '')
);
const targetShort = normalizeNodeNameValue(
segment.targetShortName ||
(targetNode ? targetNode.short_name ?? targetNode.shortName : null) ||
(targetNode && typeof targetNode.node_id === 'string' ? targetNode.node_id : '')
);
if (!sourceShort || !targetShort) return '';
const sourceLong = normalizeNodeNameValue(sourceNode?.long_name ?? sourceNode?.longName) || '';
const targetLong = normalizeNodeNameValue(targetNode?.long_name ?? targetNode?.longName) || '';
const sourceHtml = renderShortHtml(sourceShort, segment.sourceRole, sourceLong, sourceNode || {});
const targetHtml = renderShortHtml(targetShort, segment.targetRole, targetLong, targetNode || {});
const arrow = '<span class="trace-tooltip__arrow" aria-hidden="true">→</span>';
return `<div class="trace-tooltip__content">${sourceHtml}${arrow}${targetHtml}</div>`;
}
/**
* Resolve a node reference for a trace hop using cached node indices.
*
* @param {{id?: string, num?: number}|null} hop Trace hop descriptor.
* @returns {?Object} Node payload when available.
*/
function resolveNodeForHop(hop) {
if (!hop || typeof hop !== 'object') {
return null;
}
const id = typeof hop.id === 'string' ? hop.id.trim() : null;
const idCandidates = [];
if (id) {
idCandidates.push(id);
idCandidates.push(id.toUpperCase());
idCandidates.push(id.toLowerCase());
}
for (const candidate of idCandidates) {
if (candidate && nodesById instanceof Map && nodesById.has(candidate)) {
return nodesById.get(candidate);
}
}
const numericCandidates = [];
if (Number.isFinite(hop.num)) numericCandidates.push(hop.num);
const parsedFromId = parseNodeNumericRef(id);
if (parsedFromId != null) numericCandidates.push(parsedFromId);
const parsedFromNum = parseNodeNumericRef(hop.num);
if (parsedFromNum != null) numericCandidates.push(parsedFromNum);
for (const numeric of numericCandidates) {
if (Number.isFinite(numeric) && nodesByNum instanceof Map && nodesByNum.has(numeric)) {
return nodesByNum.get(numeric);
}
}
return null;
}
/**
* Derive display context for a chat log entry by inspecting node payloads.
*
@@ -2806,6 +3024,7 @@ export function initializeApp(config) {
* telemetryEntries?: Array<Object>,
* positionEntries?: Array<Object>,
* neighborEntries?: Array<Object>,
* traceEntries?: Array<Object>,
* filterQuery?: string
* }} params Render inputs.
* @returns {void}
@@ -2817,6 +3036,7 @@ export function initializeApp(config) {
telemetryEntries = [],
positionEntries = [],
neighborEntries = [],
traceEntries = [],
filterQuery = ''
}) {
if (!CHAT_ENABLED || !chatEl) return;
@@ -2831,6 +3051,7 @@ export function initializeApp(config) {
telemetry: telemetryEntries,
positions: positionEntries,
neighbors: neighborEntries,
traces: traceEntries,
messages,
logOnlyMessages: encryptedMessages,
nowSeconds,
@@ -3280,7 +3501,8 @@ export function initializeApp(config) {
const effectiveLimit = Math.min(safeLimit, NODE_LIMIT);
const r = await fetch(`/api/traces?limit=${effectiveLimit}`, { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
const traces = await r.json();
return filterRecentTraces(traces, TRACE_MAX_AGE_SECONDS);
}
/**
@@ -3340,6 +3562,28 @@ export function initializeApp(config) {
return null;
}
/**
* Filter trace entries to discard packets older than the configured window.
*
* @param {Array<Object>} traces Trace payloads.
* @param {number} [maxAgeSeconds=TRACE_MAX_AGE_SECONDS] Maximum allowed age in seconds.
* @returns {Array<Object>} Recent trace entries.
*/
function filterRecentTraces(traces, maxAgeSeconds = TRACE_MAX_AGE_SECONDS) {
if (!Array.isArray(traces)) {
return [];
}
if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) {
return [...traces];
}
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - maxAgeSeconds;
return traces.filter(trace => {
const rxTime = resolveTimestampSeconds(trace?.rx_time ?? trace?.rxTime, trace?.rx_iso ?? trace?.rxIso);
return rxTime != null && rxTime >= cutoff;
});
}
/**
* Merge recent position packets into the node list.
*
@@ -3620,6 +3864,9 @@ export function initializeApp(config) {
if (neighborLinesLayer) {
neighborLinesLayer.clearLayers();
}
if (traceLinesLayer) {
traceLinesLayer.clearLayers();
}
markersLayer.clearLayers();
const pts = [];
const nodesById = new Map();
@@ -3629,7 +3876,7 @@ export function initializeApp(config) {
if (typeof nodeId !== 'string' || nodeId.length === 0) continue;
nodesById.set(nodeId, node);
}
const traceSegments = neighborLinesLayer
const traceSegments = traceLinesLayer
? buildTraceSegments(allTraces, nodes, {
limitDistance: LIMIT_DISTANCE,
maxDistanceKm: MAX_DISTANCE_KM,
@@ -3721,6 +3968,21 @@ export function initializeApp(config) {
opacity: 0.42,
className: 'neighbor-connection-line'
}).addTo(neighborLinesLayer);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildNeighborTooltipHtml({
...segment,
sourceNode: nodesById.get(segment.sourceId),
targetNode: nodesById.get(segment.targetId)
});
if (tooltipHtml) {
polyline.bindTooltip(tooltipHtml, {
direction: 'center',
opacity: 0.92,
sticky: true,
className: 'trace-tooltip'
});
}
}
if (polyline && typeof polyline.on === 'function') {
polyline.on('click', event => {
if (event && event.originalEvent) {
@@ -3738,6 +4000,13 @@ export function initializeApp(config) {
? event.originalEvent.target
: null;
const anchorEl = polyline.getElement() || clickTarget;
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
if (polyline.isTooltipOpen()) {
polyline.closeTooltip();
} else {
polyline.openTooltip();
}
}
if (!anchorEl) return;
if (overlayStack.isOpen(anchorEl)) {
overlayStack.close(anchorEl);
@@ -3749,7 +4018,7 @@ export function initializeApp(config) {
});
}
if (neighborLinesLayer && traceSegments.length) {
if (traceLinesLayer && traceSegments.length) {
traceSegments
.sort((a, b) => {
const rxA = Number.isFinite(a.rxTime) ? a.rxTime : -Infinity;
@@ -3758,13 +4027,43 @@ export function initializeApp(config) {
return rxA - rxB;
})
.forEach(segment => {
L.polyline(segment.latlngs, {
const polyline = L.polyline(segment.latlngs, {
color: segment.color,
weight: 2,
opacity: 0.42,
dashArray: '6 6',
className: 'neighbor-connection-line trace-connection-line'
}).addTo(neighborLinesLayer);
}).addTo(traceLinesLayer);
if (polyline && typeof polyline.bindTooltip === 'function') {
const tooltipHtml = buildTraceTooltipHtml(segment.pathNodes);
if (tooltipHtml) {
polyline.bindTooltip(tooltipHtml, {
direction: 'center',
opacity: 0.92,
sticky: true,
className: 'trace-tooltip'
});
}
}
if (polyline && typeof polyline.on === 'function') {
polyline.on('click', event => {
if (event && event.originalEvent) {
if (typeof event.originalEvent.preventDefault === 'function') {
event.originalEvent.preventDefault();
}
if (typeof event.originalEvent.stopPropagation === 'function') {
event.originalEvent.stopPropagation();
}
}
if (polyline && typeof polyline.isTooltipOpen === 'function' && typeof polyline.openTooltip === 'function') {
if (polyline.isTooltipOpen()) {
polyline.closeTooltip();
} else {
polyline.openTooltip();
}
}
});
}
});
}
@@ -3914,6 +4213,7 @@ export function initializeApp(config) {
telemetryEntries: allTelemetryEntries,
positionEntries: allPositionEntries,
neighborEntries: allNeighbors,
traceEntries: allTraces,
filterQuery
});
}
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { translateRoleId } from './role-helpers.js';
/**
* Determine whether the supplied value acts like an object instance.
*
@@ -36,6 +38,18 @@ function normalizeString(value) {
return str.length === 0 ? null : str;
}
/**
* Convert a raw role value into a canonical identifier.
*
* @param {*} value Raw role candidate from the API or cached snapshots.
* @returns {string|null} Canonical role string or ``null`` when blank.
*/
function normalizeRole(value) {
if (value == null) return null;
const translated = translateRoleId(value);
return normalizeString(translated);
}
/**
* Convert a raw value into a finite number when possible.
*
@@ -61,7 +75,7 @@ const FIELD_ALIASES = Object.freeze([
{ keys: ['node_num', 'nodeNum', 'num'], normalise: normalizeNumber },
{ keys: ['short_name', 'shortName'], normalise: normalizeString },
{ keys: ['long_name', 'longName'], normalise: normalizeString },
{ keys: ['role'], normalise: normalizeString },
{ keys: ['role'], normalise: normalizeRole },
{ keys: ['hw_model', 'hwModel'], normalise: normalizeString },
{ keys: ['modem_preset', 'modemPreset'], normalise: normalizeString },
{ keys: ['lora_freq', 'loraFreq'], normalise: normalizeNumber },
+52 -3
View File
@@ -22,8 +22,26 @@
*/
function coerceFiniteNumber(value) {
if (value == null) return null;
if (typeof value === 'string' && value.trim().length === 0) return null;
const num = typeof value === 'number' ? value : Number(value);
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
if (trimmed.startsWith('!')) {
const hex = trimmed.slice(1);
if (!/^[0-9A-Fa-f]+$/.test(hex)) return null;
const parsedHex = Number.parseInt(hex, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
if (/^0[xX][0-9A-Fa-f]+$/.test(trimmed)) {
const parsedHex = Number.parseInt(trimmed, 16);
return Number.isFinite(parsedHex) ? parsedHex >>> 0 : null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
@@ -64,6 +82,19 @@ function buildNodeIndex(nodes) {
return { byId, byNum };
}
/**
* Convert a numeric node reference into the canonical hex-prefixed identifier.
*
* @param {number} ref Numeric node identifier.
* @returns {string|null} Canonical identifier or ``null`` when invalid.
*/
function canonicalNodeIdFromNumeric(ref) {
if (!Number.isFinite(ref)) return null;
const unsigned = ref >>> 0;
const hex = unsigned.toString(16).padStart(8, '0');
return `!${hex}`;
}
/**
* Locate a node by either string identifier or numeric reference.
*
@@ -80,6 +111,12 @@ function findNode(byId, byNum, ref) {
}
if (numeric != null) {
if (byNum.has(numeric)) return byNum.get(numeric) || null;
const canonicalId = canonicalNodeIdFromNumeric(numeric);
if (canonicalId) {
if (byId.has(canonicalId)) return byId.get(canonicalId) || null;
const canonicalUpper = canonicalId.toUpperCase();
if (byId.has(canonicalUpper)) return byId.get(canonicalUpper) || null;
}
const asString = String(numeric);
if (byId.has(asString)) return byId.get(asString) || null;
}
@@ -163,6 +200,8 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
const path = extractTracePath(trace);
if (path.length < 2) continue;
const rxTime = coerceFiniteNumber(trace.rx_time ?? trace.rxTime);
const nodesWithCoords = [];
const segmentsForTrace = [];
let previous = null;
for (const ref of path) {
@@ -172,8 +211,9 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
previous = null;
continue;
}
nodesWithCoords.push(node);
if (previous) {
segments.push({
segmentsForTrace.push({
latlngs: [previous.coords, coords],
color: colorResolver(previous.node),
traceId: trace.id ?? trace.packet_id ?? trace.trace_id,
@@ -182,6 +222,14 @@ export function buildTraceSegments(traces, nodes, { limitDistance = false, maxDi
}
previous = { node, coords };
}
if (segmentsForTrace.length) {
const pathNodes = nodesWithCoords.slice();
segmentsForTrace.forEach(segment => {
segment.pathNodes = pathNodes;
});
segments.push(...segmentsForTrace);
}
}
return segments;
@@ -193,4 +241,5 @@ export const __testUtils = {
findNode,
resolveNodeCoordinates,
extractTracePath,
canonicalNodeIdFromNumeric,
};
+187
View File
@@ -121,6 +121,25 @@ tbody tr:nth-child(even) td {
stroke-dasharray: 6 6;
}
.leaflet-tooltip.trace-tooltip {
background: var(--bg2);
color: var(--fg);
border: 1px solid var(--line);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
padding: 6px 8px;
font-size: 13px;
}
.trace-tooltip__content {
display: inline-flex;
align-items: center;
gap: 6px;
}
.trace-tooltip__arrow {
opacity: 0.7;
}
.neighbor-snr {
margin-left: 4px;
color: var(--muted);
@@ -1354,6 +1373,19 @@ button:not(.chat-tab):not(.sort-button):hover {
outline-offset: 2px;
}
.sort-header {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
}
.sort-header:focus-visible {
outline: 2px solid #4a90e2;
outline-offset: 2px;
}
.sort-indicator {
font-size: 0.75em;
opacity: 0.6;
@@ -1831,6 +1863,10 @@ body.dark .sort-button {
color: inherit;
}
body.dark .sort-header {
color: inherit;
}
body.dark .sort-button:hover {
background: none;
}
@@ -1948,3 +1984,154 @@ body.dark #map .leaflet-tile.map-tiles {
filter: var(--map-tiles-filter, var(--map-tile-filter-dark));
-webkit-filter: var(--map-tiles-filter, var(--map-tile-filter-dark));
}
/* ===========================
Federation Page Styles
=========================== */
.header-federation {
display: flex;
align-items: center;
gap: 12px;
}
.federation-link {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
text-decoration: none;
padding: 4px 8px;
border-radius: 8px;
transition: background-color 160ms ease, transform 160ms ease;
}
.federation-link:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
transform: scale(1.1);
}
.federation-page {
padding: 24px var(--pad) 48px;
max-width: none;
margin: 0;
width: 100%;
}
.federation-page--full-width {
padding-top: 0;
}
.federation-page__content {
display: flex;
flex-direction: column;
gap: 24px;
}
.federation-page__map-row {
width: 100%;
height: 400px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.federation-page__map-row .map-panel {
height: 100%;
}
.federation-page__map-row #map {
height: 100%;
width: 100%;
}
.instances-table-wrapper {
width: 100%;
overflow-x: auto;
}
#instances {
width: 100%;
border-collapse: collapse;
font-size: 0.95rem;
}
#instances th,
#instances td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
#instances th {
font-weight: 600;
background: var(--table-header-bg, var(--surface));
position: sticky;
top: 0;
z-index: 1;
}
#instances tbody tr:hover {
background: var(--hover-bg, rgba(255, 255, 255, 0.05));
}
#instances a {
color: var(--accent);
text-decoration: none;
}
#instances a:hover {
text-decoration: underline;
}
.instances-col--name {
min-width: 140px;
}
.instances-col--domain {
min-width: 180px;
}
.instances-col--contact {
min-width: 160px;
white-space: pre-wrap;
word-break: break-word;
}
.instances-col--version {
min-width: 80px;
}
.instances-col--channel,
.instances-col--frequency {
min-width: 100px;
}
.instances-col--nodes {
min-width: 110px;
}
.instances-col--latitude,
.instances-col--longitude {
min-width: 100px;
}
.instances-col--last-update {
min-width: 90px;
}
@media (max-width: 900px) {
.federation-page__map-row {
height: 300px;
}
.header-federation {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.federation-link {
align-self: flex-end;
}
}
+245 -1
View File
@@ -1337,6 +1337,38 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
describe "GET /federation" do
it "returns 404 when federation is disabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(false)
get "/federation"
expect(last_response.status).to eq(404)
end
it "renders the federation subpage when enabled" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).to include('class="federation-page federation-page--full-width"')
expect(last_response.body).to include("initializeFederationPage")
end
it "hides dashboard-only refresh controls while keeping manual refresh and theme toggle" do
allow(PotatoMesh::Config).to receive(:federation_enabled?).and_return(true)
get "/federation"
expect(last_response).to be_ok
expect(last_response.body).not_to include('id="autoRefresh"')
expect(last_response.body).not_to include('id="filterInput"')
expect(last_response.body).to include('id="refreshBtn"')
expect(last_response.body).to include('id="themeToggle"')
end
end
describe "GET /chat" do
it "renders the chat container when chat is enabled" do
get "/chat"
@@ -3435,6 +3467,43 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "accepts traceroutes without metrics or RSSI fields" do
allow(Time).to receive(:now).and_return(reference_time)
payload = [
{
"id" => 9_003,
"request_id" => 42,
"src" => 0xAAAA0001,
"dest" => 0xAAAA0002,
"rx_time" => reference_time.to_i - 1,
"hops" => [0xAAAA0001, 0xAAAA0003, 0xAAAA0002],
},
]
post "/api/traces", 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
stored = db.get_first_row("SELECT * FROM traces WHERE id = ?", [payload.first["id"]])
expect(stored["rx_time"]).to eq(payload.first["rx_time"])
expect(stored["rx_iso"]).to eq(Time.at(payload.first["rx_time"]).utc.iso8601)
expect(stored["rssi"]).to be_nil
expect(stored["snr"]).to be_nil
expect(stored["elapsed_ms"]).to be_nil
hops = db.execute(
"SELECT hop_index, node_id FROM trace_hops WHERE trace_id = ? ORDER BY hop_index",
[stored["id"]],
)
expect(hops.map { |row| row["node_id"] }).to eq(payload.first["hops"])
end
end
it "returns 400 when the payload is not valid JSON" do
post "/api/traces", "{", auth_headers
@@ -4402,7 +4471,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(first_entry["telemetry_time_iso"]).to eq(Time.at(latest["telemetry_time"]).utc.iso8601)
expect(first_entry).not_to have_key("device_metrics")
expect_same_value(first_entry["battery_level"], telemetry_metric(latest, "battery_level"))
expect_same_value(first_entry["current"], telemetry_metric(latest, "current"))
expected_current = telemetry_metric(latest, "current")
expect_same_value(first_entry["current"], expected_current.nil? ? nil : expected_current / 1000.0)
expect_same_value(first_entry["distance"], telemetry_metric(latest, "distance"))
expect_same_value(first_entry["lux"], telemetry_metric(latest, "lux"))
expect_same_value(first_entry["wind_direction"], telemetry_metric(latest, "wind_direction"))
@@ -4523,6 +4593,51 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(filtered.first).not_to have_key("battery_level")
expect(filtered.first).not_to have_key("portnum")
end
it "omits zero-valued battery and voltage metrics from telemetry responses" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage, uptime_seconds, channel_utilization) VALUES(?,?,?,?,?,?,?,?,?)",
[
88,
"!tele-zero",
now,
Time.at(now).utc.iso8601,
now - 60,
0,
0,
0,
0.5,
],
)
end
get "/api/telemetry"
expect(last_response).to be_ok
rows = JSON.parse(last_response.body)
expect(rows.length).to eq(1)
entry = rows.first
expect(entry["node_id"]).to eq("!tele-zero")
expect(entry["rx_time"]).to eq(now)
expect(entry["telemetry_time"]).to eq(now - 60)
expect(entry).not_to have_key("battery_level")
expect(entry).not_to have_key("voltage")
expect(entry["uptime_seconds"]).to eq(0)
expect(entry["channel_utilization"]).to eq(0.5)
get "/api/telemetry/!tele-zero"
expect(last_response).to be_ok
scoped_rows = JSON.parse(last_response.body)
expect(scoped_rows.length).to eq(1)
expect(scoped_rows.first).not_to have_key("battery_level")
expect(scoped_rows.first).not_to have_key("voltage")
end
end
describe "GET /api/telemetry/aggregated" do
@@ -4544,6 +4659,35 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(a_bucket["aggregates"]).to have_key("battery_level")
expect(a_bucket["aggregates"]["battery_level"]).to include("avg")
expect(a_bucket).not_to have_key("device_metrics")
buckets_by_start = {}
buckets.each do |bucket|
start_time = bucket["bucket_start"]
buckets_by_start[start_time] = bucket if start_time
end
bucket_seconds = 300
current_by_bucket = Hash.new { |hash, key| hash[key] = [] }
telemetry_fixture.each do |entry|
timestamp = entry["rx_time"] || entry["telemetry_time"]
next unless timestamp
bucket_start = (timestamp / bucket_seconds) * bucket_seconds
current_value = telemetry_metric(entry, "current")
next if current_value.nil?
current_by_bucket[bucket_start] << current_value
end
current_by_bucket.each do |bucket_start, values|
bucket = buckets_by_start[bucket_start]
next unless bucket
aggregates = bucket.fetch("aggregates", {})
metrics = aggregates["current"]
expect(metrics).not_to be_nil
expect_same_value(metrics["avg"], values.sum / values.length / 1000.0)
expect_same_value(metrics["min"], values.min / 1000.0)
expect_same_value(metrics["max"], values.max / 1000.0)
end
end
it "applies default window and bucket sizes when parameters are omitted" do
@@ -4558,6 +4702,86 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(buckets.first["bucket_seconds"]).to eq(PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS)
end
it "omits zero-valued battery and voltage aggregates" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage, channel_utilization) VALUES(?,?,?,?,?,?,?,?)",
[
991,
"!tele-agg-zero",
now,
Time.at(now).utc.iso8601,
now - 30,
0,
0,
0.25,
],
)
end
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300"
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).not_to have_key("battery_level")
expect(aggregates).not_to have_key("voltage")
expect(aggregates.dig("channel_utilization", "avg")).to eq(0.25)
end
it "ignores zero-valued telemetry when aggregating mixed buckets" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
with_db do |db|
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?)",
[
992,
"!tele-agg-mixed",
now,
Time.at(now).utc.iso8601,
now - 120,
0,
0,
],
)
db.execute(
"INSERT INTO telemetry(id, node_id, rx_time, rx_iso, telemetry_time, battery_level, voltage) VALUES(?,?,?,?,?,?,?)",
[
993,
"!tele-agg-mixed",
now,
Time.at(now).utc.iso8601,
now - 60,
80.0,
3.7,
],
)
end
get "/api/telemetry/aggregated?windowSeconds=3600&bucketSeconds=300"
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(aggregates.dig("battery_level", "avg")).to eq(80.0)
expect(aggregates.dig("battery_level", "min")).to eq(80.0)
expect(aggregates.dig("battery_level", "max")).to eq(80.0)
expect(aggregates.dig("voltage", "avg")).to eq(3.7)
expect(aggregates.dig("voltage", "min")).to eq(3.7)
expect(aggregates.dig("voltage", "max")).to eq(3.7)
end
it "rejects invalid bucket and window parameters" do
get "/api/telemetry/aggregated?windowSeconds=0&bucketSeconds=300"
expect(last_response.status).to eq(400)
@@ -4625,6 +4849,26 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
expect(JSON.parse(last_response.body)).to eq([])
end
it "excludes traces older than one week" do
clear_database
now = Time.now.to_i
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
payload = [
{ "id" => 50_001, "src" => 1, "dest" => 2, "rx_time" => recent_rx, "metrics" => {} },
{ "id" => 50_002, "src" => 3, "dest" => 4, "rx_time" => stale_rx, "metrics" => {} },
]
post "/api/traces", payload.to_json, auth_headers
expect(last_response).to be_ok
get "/api/traces"
expect(last_response).to be_ok
ids = JSON.parse(last_response.body).map { |row| row["id"] }
expect(ids).to eq([50_001])
end
end
describe "GET /nodes/:id" do
+16
View File
@@ -183,4 +183,20 @@ RSpec.describe PotatoMesh::App::Database do
hop_columns = column_names_for("trace_hops")
expect(hop_columns).to include("trace_id", "hop_index", "node_id")
end
it "adds the contact_link column to existing instances tables" do
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
db.execute("CREATE TABLE nodes(node_id TEXT)")
db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)")
db.execute(
"CREATE TABLE instances(id TEXT PRIMARY KEY, domain TEXT, pubkey TEXT, last_update_time INTEGER, is_private INTEGER)",
)
end
expect(column_names_for("instances")).not_to include("contact_link")
harness_class.ensure_schema_upgrades
expect(column_names_for("instances")).to include("contact_link")
end
end
+108
View File
@@ -17,6 +17,7 @@
require "spec_helper"
require "net/http"
require "openssl"
require "sqlite3"
require "set"
require "uri"
require "socket"
@@ -322,6 +323,113 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe ".upsert_instance_record" do
let(:application_class) { PotatoMesh::Application }
let(:base_attributes) do
{
id: "remote-instance",
domain: "Remote.Mesh",
pubkey: PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
name: "Remote Mesh",
version: "1.0.0",
channel: "longfox",
frequency: "915",
latitude: 45.0,
longitude: -122.0,
last_update_time: Time.now.to_i,
is_private: false,
contact_link: "https://example.org/contact",
}
end
def with_db
db = SQLite3::Database.new(PotatoMesh::Config.db_path)
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
db.execute("PRAGMA foreign_keys = ON")
yield db
ensure
db&.close
end
before do
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
application_class.init_db unless application_class.db_schema_present?
application_class.ensure_schema_upgrades
with_db do |db|
db.execute("DELETE FROM instances")
end
allow(federation_helpers).to receive(:ip_from_domain).and_return(nil)
end
it "inserts the contact_link for new records" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
stored = db.get_first_value("SELECT contact_link FROM instances WHERE id = ?", base_attributes[:id])
expect(stored).to eq("https://example.org/contact")
end
end
it "updates the contact_link on conflict" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
federation_helpers.send(
:upsert_instance_record,
db,
base_attributes.merge(contact_link: "https://example.org/new-contact", name: "Renamed Mesh"),
"sig-2",
)
row =
db.get_first_row("SELECT contact_link, name, signature FROM instances WHERE id = ?", base_attributes[:id])
expect(row[0]).to eq("https://example.org/new-contact")
expect(row[1]).to eq("Renamed Mesh")
expect(row[2]).to eq("sig-2")
end
end
it "allows the contact_link to be cleared" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-1")
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(contact_link: nil), "sig-3")
row = db.get_first_row("SELECT contact_link, signature FROM instances WHERE id = ?", base_attributes[:id])
expect(row[0]).to be_nil
expect(row[1]).to eq("sig-3")
end
end
it "stores the nodes_count for new records" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 77), "sig-1")
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
expect(stored).to eq(77)
end
end
it "updates the nodes_count on conflict" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 12), "sig-1")
federation_helpers.send(
:upsert_instance_record,
db,
base_attributes.merge(nodes_count: 99, name: "Renamed Mesh"),
"sig-2",
)
row =
db.get_first_row("SELECT nodes_count, name, signature FROM instances WHERE id = ?", base_attributes[:id])
expect(row[0]).to eq(99)
expect(row[1]).to eq("Renamed Mesh")
expect(row[2]).to eq("sig-2")
end
end
end
describe ".federation_user_agent_header" do
it "combines the version and sanitized domain" do
allow(federation_helpers).to receive(:app_constant).and_call_original
+81
View File
@@ -38,6 +38,7 @@ RSpec.describe PotatoMesh::App::Instances do
before do
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
application_class.init_db unless application_class.db_schema_present?
application_class.ensure_schema_upgrades
with_db do |db|
db.execute("DELETE FROM instances")
end
@@ -95,5 +96,85 @@ RSpec.describe PotatoMesh::App::Instances do
expect(domains).not_to include("missing.mesh.test")
expect(payload.all? { |row| row["lastUpdateTime"] >= lower_bound }).to be(true)
end
it "exposes contactLink when present and omits blank values" do
fixed_time = Time.utc(2025, 2, 1, 12, 0, 0)
allow(Time).to receive(:now).and_return(fixed_time)
with_db do |db|
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, contact_link) VALUES (?, ?, ?, ?, ?, ?)",
[
"instance-with-contact",
"alpha.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
fixed_time.to_i,
0,
" https://example.org/contact ",
],
)
db.execute(
"INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, contact_link) VALUES (?, ?, ?, ?, ?, ?)",
[
"instance-without-contact",
"beta.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
fixed_time.to_i,
0,
" \t ",
],
)
end
payload = application_class.load_instances_for_api
with_contact = payload.find { |row| row["domain"] == "alpha.mesh.test" }
without_contact = payload.find { |row| row["domain"] == "beta.mesh.test" }
expect(with_contact["contactLink"]).to eq("https://example.org/contact")
expect(without_contact.key?("contactLink")).to be(false)
end
it "includes nodesCount values, preserving zeros" do
fixed_time = Time.utc(2025, 2, 2, 8, 0, 0)
allow(Time).to receive(:now).and_return(fixed_time)
with_db do |db|
db.execute(
<<~SQL,
INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count)
VALUES (?, ?, ?, ?, ?, ?)
SQL
[
"instance-with-nodes",
"gamma.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
fixed_time.to_i,
0,
42,
],
)
db.execute(
<<~SQL,
INSERT INTO instances (id, domain, pubkey, last_update_time, is_private, nodes_count)
VALUES (?, ?, ?, ?, ?, ?)
SQL
[
"instance-with-zero",
"delta.mesh.test",
PotatoMesh::Application::INSTANCE_PUBLIC_KEY_PEM,
fixed_time.to_i,
0,
0,
],
)
end
payload = application_class.load_instances_for_api
with_nodes = payload.find { |row| row["domain"] == "gamma.mesh.test" }
zero_nodes = payload.find { |row| row["domain"] == "delta.mesh.test" }
expect(with_nodes["nodesCount"]).to eq(42)
expect(zero_nodes["nodesCount"]).to eq(0)
end
end
end
+27
View File
@@ -0,0 +1,27 @@
<!--
Copyright © 2025-26 l5yth & contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="federation-page federation-page--full-width">
<div class="federation-page__content">
<div class="federation-page__map-row">
<%= erb :"shared/_map_panel", locals: { full_screen: true } %>
</div>
<%= erb :"shared/_instances_table" %>
</div>
</section>
<script type="module">
import { initializeFederationPage } from '/assets/js/app/federation-page.js';
initializeFederationPage();
</script>
+15 -9
View File
@@ -77,12 +77,14 @@
main_classes << "page-main--full-screen" if full_screen_view
show_header = !full_screen_view
show_meta_info = true
show_auto_refresh_controls = true
show_auto_refresh_controls = view_mode != :federation
show_auto_fit_toggle = %i[dashboard map].include?(view_mode)
map_zoom_override = defined?(map_zoom) ? map_zoom : nil
show_info_button = !full_screen_view
show_footer = !full_screen_view
show_filter_input = !%i[node_detail charts].include?(view_mode)
show_filter_input = !%i[node_detail charts federation].include?(view_mode)
show_auto_refresh_toggle = show_auto_refresh_controls
show_refresh_actions = show_auto_refresh_controls || view_mode == :federation
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
@@ -104,11 +106,13 @@
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
<div class="header-federation">
<div class="instance-selector">
<label class="visually-hidden" for="instanceSelect">Select a region</label>
<select id="instanceSelect" class="instance-select" aria-label="Select instance region">
<option value=""><%= Rack::Utils.escape_html("Select region ...") %></option>
</select>
</div>
</div>
<% end %>
</header>
@@ -123,9 +127,11 @@
<% else %>
<p id="refreshInfo" class="<%= refresh_info_classes.join(" ") %>" aria-live="polite"></p>
<% end %>
<% if show_auto_refresh_controls %>
<% if show_refresh_actions %>
<div class="refresh-actions">
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
<% if show_auto_refresh_toggle %>
<label class="auto-refresh-toggle"><input type="checkbox" id="autoRefresh" checked /> Auto-refresh every <%= refresh_interval_seconds %> seconds</label>
<% end %>
<button id="refreshBtn" type="button">Refresh now</button>
<span id="status" class="pill">loading…</span>
</div>
+34
View File
@@ -0,0 +1,34 @@
<!--
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.
-->
<div class="instances-table-wrapper">
<table id="instances">
<thead>
<tr>
<th class="instances-col instances-col--name" data-sort-key="name"><span class="sort-header" role="button" tabindex="0" data-sort-key="name" data-sort-label="Name">Name <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--domain" data-sort-key="domain"><span class="sort-header" role="button" tabindex="0" data-sort-key="domain" data-sort-label="Domain">Domain <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--contact" data-sort-key="contact"><span class="sort-header" role="button" tabindex="0" data-sort-key="contact" data-sort-label="Contact">Contact <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--version" data-sort-key="version"><span class="sort-header" role="button" tabindex="0" data-sort-key="version" data-sort-label="Version">Version <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--channel" data-sort-key="channel"><span class="sort-header" role="button" tabindex="0" data-sort-key="channel" data-sort-label="Channel">Channel <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--frequency" data-sort-key="frequency"><span class="sort-header" role="button" tabindex="0" data-sort-key="frequency" data-sort-label="Frequency">Frequency <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--nodes" data-sort-key="nodesCount"><span class="sort-header" role="button" tabindex="0" data-sort-key="nodesCount" data-sort-label="Active Nodes (24h)">Active Nodes (24h) <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--latitude" data-sort-key="latitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="latitude" data-sort-label="Latitude">Latitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--longitude" data-sort-key="longitude"><span class="sort-header" role="button" tabindex="0" data-sort-key="longitude" data-sort-label="Longitude">Longitude <span class="sort-indicator" aria-hidden="true"></span></span></th>
<th class="instances-col instances-col--last-update" data-sort-key="lastUpdateTime"><span class="sort-header" role="button" tabindex="0" data-sort-key="lastUpdateTime" data-sort-label="Last Update">Last Update <span class="sort-indicator" aria-hidden="true"></span></span></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>