Compare commits

..

12 Commits

Author SHA1 Message Date
l5y f8660907e5 matrix: cover missing unit test vectors 2026-01-06 18:05:23 +01:00
l5y 96b2942065 matrix: fix tests 2026-01-06 17:56:20 +01:00
l5y 4f0410c7da matrix: add docker env boilerplate 2026-01-06 17:54:50 +01:00
l5y e9a0dc0d59 web: fix stale node queries (#603) 2026-01-06 16:13:04 +01:00
l5y d75c395514 matrix: move short name to display name (#602)
* matrix: move short name to display name

* matrix: run fmt
2026-01-05 23:24:27 +01:00
l5y b08f951780 ci: update ruby to 4 (#601)
* ci: update ruby to 4

* ci: update dispatch triggers
2026-01-05 23:23:56 +01:00
l5y 955431ac18 web: display traces of last 28 days if available (#599)
* web: display traces of last 28 days if available

* web: address review comments

* web: fix tests

* web: fix tests
2026-01-05 21:22:16 +01:00
l5y 7f40abf92a web: establish menu structure (#597)
* web: establish menu structure

* web: cover missing unit test vectors

* web: fix tests
2026-01-05 21:18:51 +01:00
l5y c157fd481b matrix: fixed the text-message checkpoint regression (#595)
* matrix: fixed the text-message checkpoint regression

* matrix: improve formatting

* matrix: fix tests
2026-01-05 18:20:25 +01:00
l5y a6fc7145bc matrix: cache seen messages by rx_time not id (#594)
* matrix: cache seen messages by rx_time not id

* matrix: fix review comments

* matrix: fix review comments

* matrix: cover missing unit test vectors

* matrix: fix tests
2026-01-05 17:34:54 +01:00
l5y ca05cbb2c5 web: hide the default '0' tab when not active (#593) 2026-01-05 16:26:56 +01:00
l5y 5c79572c4d matrix: fix empty bridge state json (#592)
* matrix: fix empty bridge state json

* matrix: fix tests
2026-01-05 16:11:24 +01:00
37 changed files with 2855 additions and 1308 deletions
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'web/**'
- 'tests/**'
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'app/**'
- 'tests/**'
+1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'data/**'
- 'tests/**'
+2 -1
View File
@@ -20,6 +20,7 @@ on:
pull_request:
branches: [ "main" ]
paths:
- '.github/**'
- 'web/**'
- 'tests/**'
@@ -34,7 +35,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
ruby-version: ['3.3', '3.4']
ruby-version: ['3.4', '4.0']
steps:
- uses: actions/checkout@v5
+127
View File
@@ -11,6 +11,56 @@ dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
@@ -85,6 +135,52 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "colored"
version = "3.0.0"
@@ -317,6 +413,12 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.4.0"
@@ -572,6 +674,12 @@ dependencies = [
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.15"
@@ -721,6 +829,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.75"
@@ -817,6 +931,7 @@ name = "potatomesh-matrix-bridge"
version = "0.5.9"
dependencies = [
"anyhow",
"clap",
"mockito",
"reqwest",
"serde",
@@ -1309,6 +1424,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -1681,6 +1802,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
+1
View File
@@ -27,6 +27,7 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
urlencoding = "2"
clap = { version = "4", features = ["derive"] }
[dev-dependencies]
tempfile = "3"
+63 -1
View File
@@ -54,7 +54,9 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
## Configuration
All configuration is in `Config.toml` in the project root.
Configuration can come from TOML, CLI flags, and environment variables. The TOML
file is optional as long as every required setting is supplied via CLI/env/secret
overrides.
Example:
@@ -80,6 +82,66 @@ room_id = "!yourroomid:example.org"
state_file = "bridge_state.json"
````
### CLI Overrides
Run `potatomesh-matrix-bridge --help` for the full list. The most common flags:
- `--config` (or `--config-path`) to point at a TOML file
- `--state-file`
- `--potatomesh-base-url`
- `--potatomesh-poll-interval-secs`
- `--matrix-homeserver`
- `--matrix-as-token`
- `--matrix-server-name`
- `--matrix-room-id`
- `--container-defaults` / `--no-container-defaults`
### Environment Overrides
Environment variables override CLI and TOML values:
- `POTATOMESH_BASE_URL`
- `POTATOMESH_POLL_INTERVAL_SECS`
- `MATRIX_HOMESERVER`
- `MATRIX_AS_TOKEN`
- `MATRIX_SERVER_NAME`
- `MATRIX_ROOM_ID`
- `STATE_FILE`
- `POTATOMESH_CONFIG_PATH` (optional TOML path)
- `POTATOMESH_CONTAINER_DEFAULTS` (`1/0`, `true/false`)
- `POTATOMESH_SECRETS_DIR` (default secrets directory)
- `CONTAINER` (container detection hint)
### Docker Secrets
Every env var above supports a `*_FILE` companion (for example, `MATRIX_AS_TOKEN_FILE`).
When present, the bridge reads the file contents and uses them instead of the plain env var.
If `POTATOMESH_SECRETS_DIR` is set (or container defaults are enabled), the bridge also
checks for files named after the env vars (for example, `/run/secrets/MATRIX_AS_TOKEN`)
even when the `*_FILE` variable is not set.
### Precedence
From highest to lowest:
1. `*_FILE` secret values (explicit or default secrets directory)
2. Environment variables
3. CLI flags
4. TOML config
5. Built-in defaults
### Container Defaults
When container defaults are enabled (auto-detected or forced):
- Default config path: `/app/Config.toml`
- Default state file: `/app/bridge_state.json`
- Default secrets directory: `/run/secrets`
- Default poll interval: 120 seconds
Disable container defaults with `--no-container-defaults` or set
`POTATOMESH_CONTAINER_DEFAULTS=0`.
### PotatoMesh API
The bridge assumes:
+5
View File
@@ -15,6 +15,11 @@
set -e
# Surface container detection for the bridge and set default secret directory.
export CONTAINER="${CONTAINER:-1}"
export POTATOMESH_CONTAINER_DEFAULTS="${POTATOMESH_CONTAINER_DEFAULTS:-1}"
export POTATOMESH_SECRETS_DIR="${POTATOMESH_SECRETS_DIR:-/run/secrets}"
# Default state file path from Config.toml unless overridden.
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
STATE_DIR="$(dirname "$STATE_FILE")"
+159
View File
@@ -0,0 +1,159 @@
// 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.
use clap::Parser;
use crate::config::{
BootstrapOverrides, ConfigOverrides, MatrixOverrides, PotatomeshOverrides, StateOverrides,
};
/// Command-line overrides for the Matrix bridge.
#[derive(Debug, Parser)]
#[command(name = "potatomesh-matrix-bridge", version)]
pub struct Cli {
/// TOML config path (optional, defaults to Config.toml or /app/Config.toml in containers).
#[arg(long = "config", alias = "config-path")]
pub config_path: Option<String>,
/// Override the state file path.
#[arg(long)]
pub state_file: Option<String>,
/// Override the PotatoMesh base URL.
#[arg(long)]
pub potatomesh_base_url: Option<String>,
/// Override the PotatoMesh poll interval in seconds.
#[arg(long)]
pub potatomesh_poll_interval_secs: Option<u64>,
/// Override the Matrix homeserver URL.
#[arg(long)]
pub matrix_homeserver: Option<String>,
/// Override the Matrix appservice access token.
#[arg(long)]
pub matrix_as_token: Option<String>,
/// Override the Matrix server name.
#[arg(long)]
pub matrix_server_name: Option<String>,
/// Override the Matrix room ID.
#[arg(long)]
pub matrix_room_id: Option<String>,
/// Force container defaults on even if container detection is false.
#[arg(long, conflicts_with = "no_container_defaults")]
pub container_defaults: bool,
/// Disable container defaults even if a container is detected.
#[arg(long, conflicts_with = "container_defaults")]
pub no_container_defaults: bool,
}
impl Cli {
/// Convert CLI flags to bootstrap overrides for config loading.
pub fn into_overrides(self) -> BootstrapOverrides {
let container_defaults = if self.container_defaults {
Some(true)
} else if self.no_container_defaults {
Some(false)
} else {
None
};
BootstrapOverrides {
config_path: self.config_path,
container_defaults,
values: ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: self.potatomesh_base_url,
poll_interval_secs: self.potatomesh_poll_interval_secs,
},
matrix: MatrixOverrides {
homeserver: self.matrix_homeserver,
as_token: self.matrix_as_token,
server_name: self.matrix_server_name,
room_id: self.matrix_room_id,
},
state: StateOverrides {
state_file: self.state_file,
},
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_overrides_map_to_config() {
let cli = Cli::parse_from([
"bridge",
"--config",
"/tmp/Config.toml",
"--state-file",
"/tmp/state.json",
"--potatomesh-base-url",
"https://potato.example/",
"--potatomesh-poll-interval-secs",
"15",
"--matrix-homeserver",
"https://matrix.example.org",
"--matrix-as-token",
"token",
"--matrix-server-name",
"example.org",
"--matrix-room-id",
"!room:example.org",
"--container-defaults",
]);
let overrides = cli.into_overrides();
assert_eq!(overrides.config_path.as_deref(), Some("/tmp/Config.toml"));
assert_eq!(overrides.container_defaults, Some(true));
assert_eq!(
overrides.values.potatomesh.base_url.as_deref(),
Some("https://potato.example/")
);
assert_eq!(overrides.values.potatomesh.poll_interval_secs, Some(15));
assert_eq!(
overrides.values.matrix.homeserver.as_deref(),
Some("https://matrix.example.org")
);
assert_eq!(overrides.values.matrix.as_token.as_deref(), Some("token"));
assert_eq!(
overrides.values.matrix.server_name.as_deref(),
Some("example.org")
);
assert_eq!(
overrides.values.matrix.room_id.as_deref(),
Some("!room:example.org")
);
assert_eq!(
overrides.values.state.state_file.as_deref(),
Some("/tmp/state.json")
);
}
#[test]
fn cli_can_disable_container_defaults() {
let cli = Cli::parse_from(["bridge", "--no-container-defaults"]);
let overrides = cli.into_overrides();
assert_eq!(overrides.container_defaults, Some(false));
}
}
+700 -2
View File
@@ -13,14 +13,40 @@
// limitations under the License.
use serde::Deserialize;
use std::{fs, path::Path};
use std::{
env, fs,
path::{Path, PathBuf},
};
const DEFAULT_CONFIG_PATH: &str = "Config.toml";
const DEFAULT_CONTAINER_CONFIG_PATH: &str = "/app/Config.toml";
const DEFAULT_STATE_FILE: &str = "bridge_state.json";
const DEFAULT_CONTAINER_STATE_FILE: &str = "/app/bridge_state.json";
const DEFAULT_POLL_INTERVAL_SECS: u64 = 60;
const DEFAULT_CONTAINER_POLL_INTERVAL_SECS: u64 = 120;
const DEFAULT_SECRETS_DIR: &str = "/run/secrets";
const ENV_CONTAINER: &str = "CONTAINER";
const ENV_CONTAINER_DEFAULTS: &str = "POTATOMESH_CONTAINER_DEFAULTS";
const ENV_CONFIG_PATH: &str = "POTATOMESH_CONFIG_PATH";
const ENV_SECRETS_DIR: &str = "POTATOMESH_SECRETS_DIR";
const ENV_POTATOMESH_BASE_URL: &str = "POTATOMESH_BASE_URL";
const ENV_POTATOMESH_POLL_INTERVAL: &str = "POTATOMESH_POLL_INTERVAL_SECS";
const ENV_MATRIX_HOMESERVER: &str = "MATRIX_HOMESERVER";
const ENV_MATRIX_AS_TOKEN: &str = "MATRIX_AS_TOKEN";
const ENV_MATRIX_SERVER_NAME: &str = "MATRIX_SERVER_NAME";
const ENV_MATRIX_ROOM_ID: &str = "MATRIX_ROOM_ID";
const ENV_STATE_FILE: &str = "STATE_FILE";
/// Configuration for the PotatoMesh API access.
#[derive(Debug, Deserialize, Clone)]
pub struct PotatomeshConfig {
pub base_url: String,
pub poll_interval_secs: u64,
}
/// Configuration for Matrix appservice access.
#[derive(Debug, Deserialize, Clone)]
pub struct MatrixConfig {
pub homeserver: String,
@@ -29,11 +55,13 @@ pub struct MatrixConfig {
pub room_id: String,
}
/// Configuration for persisted bridge state.
#[derive(Debug, Deserialize, Clone)]
pub struct StateConfig {
pub state_file: String,
}
/// Complete bridge configuration, merged from file and overrides.
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
pub potatomesh: PotatomeshConfig,
@@ -41,20 +69,466 @@ pub struct Config {
pub state: StateConfig,
}
/// Optional configuration overrides for a single section.
#[derive(Debug, Clone, Default)]
pub struct PotatomeshOverrides {
pub base_url: Option<String>,
pub poll_interval_secs: Option<u64>,
}
/// Optional Matrix overrides.
#[derive(Debug, Clone, Default)]
pub struct MatrixOverrides {
pub homeserver: Option<String>,
pub as_token: Option<String>,
pub server_name: Option<String>,
pub room_id: Option<String>,
}
/// Optional state overrides.
#[derive(Debug, Clone, Default)]
pub struct StateOverrides {
pub state_file: Option<String>,
}
/// Override bundle merged from TOML, CLI, env, and secret files.
#[derive(Debug, Clone, Default)]
pub struct ConfigOverrides {
pub potatomesh: PotatomeshOverrides,
pub matrix: MatrixOverrides,
pub state: StateOverrides,
}
/// Runtime context discovered while bootstrapping configuration.
#[derive(Debug, Clone)]
pub struct RuntimeContext {
pub in_container: bool,
pub container_defaults: bool,
pub config_path: String,
pub secrets_dir: Option<PathBuf>,
}
/// Bootstrapped configuration and runtime context.
#[derive(Debug, Clone)]
pub struct ConfigBootstrap {
pub config: Config,
pub context: RuntimeContext,
}
/// CLI-provided override bundle with container defaults toggles.
#[derive(Debug, Clone, Default)]
pub struct BootstrapOverrides {
pub config_path: Option<String>,
pub container_defaults: Option<bool>,
pub values: ConfigOverrides,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct PotatomeshFileOverrides {
#[serde(default)]
base_url: Option<String>,
#[serde(default)]
poll_interval_secs: Option<u64>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct MatrixFileOverrides {
#[serde(default)]
homeserver: Option<String>,
#[serde(default)]
as_token: Option<String>,
#[serde(default)]
server_name: Option<String>,
#[serde(default)]
room_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct StateFileOverrides {
#[serde(default)]
state_file: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
struct ConfigFileOverrides {
#[serde(default)]
potatomesh: PotatomeshFileOverrides,
#[serde(default)]
matrix: MatrixFileOverrides,
#[serde(default)]
state: StateFileOverrides,
}
impl ConfigOverrides {
/// Merge another override set, replacing only fields present in `other`.
pub fn merge(&mut self, other: ConfigOverrides) {
self.potatomesh.merge(other.potatomesh);
self.matrix.merge(other.matrix);
self.state.merge(other.state);
}
}
impl PotatomeshOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: PotatomeshOverrides) {
if other.base_url.is_some() {
self.base_url = other.base_url;
}
if other.poll_interval_secs.is_some() {
self.poll_interval_secs = other.poll_interval_secs;
}
}
}
impl MatrixOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: MatrixOverrides) {
if other.homeserver.is_some() {
self.homeserver = other.homeserver;
}
if other.as_token.is_some() {
self.as_token = other.as_token;
}
if other.server_name.is_some() {
self.server_name = other.server_name;
}
if other.room_id.is_some() {
self.room_id = other.room_id;
}
}
}
impl StateOverrides {
/// Merge optional fields, keeping existing values when the override is empty.
fn merge(&mut self, other: StateOverrides) {
if other.state_file.is_some() {
self.state_file = other.state_file;
}
}
}
impl From<ConfigFileOverrides> for ConfigOverrides {
fn from(value: ConfigFileOverrides) -> Self {
Self {
potatomesh: PotatomeshOverrides {
base_url: value.potatomesh.base_url,
poll_interval_secs: value.potatomesh.poll_interval_secs,
},
matrix: MatrixOverrides {
homeserver: value.matrix.homeserver,
as_token: value.matrix.as_token,
server_name: value.matrix.server_name,
room_id: value.matrix.room_id,
},
state: StateOverrides {
state_file: value.state.state_file,
},
}
}
}
/// Detect container context from env or cgroup hints.
fn detect_container() -> bool {
let env_value = env::var(ENV_CONTAINER).ok();
let cgroup_contents = fs::read_to_string("/proc/1/cgroup").ok();
detect_container_from(env_value.as_deref(), cgroup_contents.as_deref())
}
/// Detect container context from provided inputs (used for testing).
fn detect_container_from(env_value: Option<&str>, cgroup_contents: Option<&str>) -> bool {
if let Some(value) = env_value.map(str::trim).filter(|v| !v.is_empty()) {
let normalized = value.to_ascii_lowercase();
return normalized != "0" && normalized != "false";
}
if let Some(cgroup) = cgroup_contents {
let haystack = cgroup.to_lowercase();
return haystack.contains("docker")
|| haystack.contains("containerd")
|| haystack.contains("kubepods")
|| haystack.contains("podman")
|| haystack.contains("lxc");
}
false
}
/// Read an environment variable, trimming whitespace and ignoring empty values.
fn read_env_string(key: &str) -> Option<String> {
env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Parse a boolean env var, accepting common truthy/falsey values.
fn read_env_bool(key: &str) -> anyhow::Result<Option<bool>> {
let raw = match read_env_string(key) {
Some(value) => value,
None => return Ok(None),
};
let normalized = raw.to_ascii_lowercase();
let parsed = match normalized.as_str() {
"1" | "true" | "yes" | "on" => true,
"0" | "false" | "no" | "off" => false,
_ => {
return Err(anyhow::anyhow!(
"Invalid boolean value for {}: {}",
key,
raw
))
}
};
Ok(Some(parsed))
}
/// Parse a u64 env var with context in error messages.
fn read_env_u64(key: &str) -> anyhow::Result<Option<u64>> {
let raw = match read_env_string(key) {
Some(value) => value,
None => return Ok(None),
};
let parsed = raw
.parse::<u64>()
.map_err(|err| anyhow::anyhow!("Invalid integer value for {}: {} ({})", key, raw, err))?;
Ok(Some(parsed))
}
/// Load a secret value from a file path and trim trailing whitespace.
fn read_secret_file(path: &Path) -> anyhow::Result<String> {
let raw = fs::read_to_string(path)?;
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!("Secret file {} is empty", path.display());
}
Ok(trimmed)
}
/// Resolve a *_FILE env var or default secrets file.
fn read_secret_value(var_name: &str, secrets_dir: Option<&Path>) -> anyhow::Result<Option<String>> {
let file_env = format!("{}_FILE", var_name);
if let Some(path) = read_env_string(&file_env) {
return Ok(Some(read_secret_file(Path::new(&path))?));
}
if let Some(dir) = secrets_dir {
let path = dir.join(var_name);
if path.exists() {
return Ok(Some(read_secret_file(&path)?));
}
}
Ok(None)
}
/// Load a config file if it exists, returning overrides for present fields.
fn load_optional_config(path: &str) -> anyhow::Result<Option<ConfigOverrides>> {
if !Path::new(path).exists() {
return Ok(None);
}
let contents = fs::read_to_string(path)?;
let cfg: ConfigFileOverrides = toml::from_str(&contents)?;
Ok(Some(cfg.into()))
}
/// Build overrides from environment variables (non-secret values).
fn env_overrides() -> anyhow::Result<ConfigOverrides> {
Ok(ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: read_env_string(ENV_POTATOMESH_BASE_URL),
poll_interval_secs: read_env_u64(ENV_POTATOMESH_POLL_INTERVAL)?,
},
matrix: MatrixOverrides {
homeserver: read_env_string(ENV_MATRIX_HOMESERVER),
as_token: read_env_string(ENV_MATRIX_AS_TOKEN),
server_name: read_env_string(ENV_MATRIX_SERVER_NAME),
room_id: read_env_string(ENV_MATRIX_ROOM_ID),
},
state: StateOverrides {
state_file: read_env_string(ENV_STATE_FILE),
},
})
}
/// Build overrides from secret files.
fn secret_overrides(secrets_dir: Option<&Path>) -> anyhow::Result<ConfigOverrides> {
let poll_interval = match read_secret_value(ENV_POTATOMESH_POLL_INTERVAL, secrets_dir)? {
Some(value) => Some(value.parse::<u64>().map_err(|err| {
anyhow::anyhow!(
"Invalid integer value for {} in secret file: {}",
ENV_POTATOMESH_POLL_INTERVAL,
err
)
})?),
None => None,
};
Ok(ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: read_secret_value(ENV_POTATOMESH_BASE_URL, secrets_dir)?,
poll_interval_secs: poll_interval,
},
matrix: MatrixOverrides {
homeserver: read_secret_value(ENV_MATRIX_HOMESERVER, secrets_dir)?,
as_token: read_secret_value(ENV_MATRIX_AS_TOKEN, secrets_dir)?,
server_name: read_secret_value(ENV_MATRIX_SERVER_NAME, secrets_dir)?,
room_id: read_secret_value(ENV_MATRIX_ROOM_ID, secrets_dir)?,
},
state: StateOverrides {
state_file: read_secret_value(ENV_STATE_FILE, secrets_dir)?,
},
})
}
/// Resolve the effective secrets directory for default *_FILE lookups.
fn resolve_secrets_dir(container_defaults: bool) -> Option<PathBuf> {
if let Some(dir) = read_env_string(ENV_SECRETS_DIR) {
return Some(PathBuf::from(dir));
}
if container_defaults {
return Some(PathBuf::from(DEFAULT_SECRETS_DIR));
}
None
}
/// Resolve the config path, honoring env and CLI overrides.
fn resolve_config_path(container_defaults: bool, overrides: &BootstrapOverrides) -> String {
if let Some(path) = read_env_string(ENV_CONFIG_PATH) {
return path;
}
if let Some(path) = &overrides.config_path {
return path.clone();
}
if container_defaults {
DEFAULT_CONTAINER_CONFIG_PATH.to_string()
} else {
DEFAULT_CONFIG_PATH.to_string()
}
}
/// Resolve whether container defaults should be active.
fn resolve_container_defaults(
in_container: bool,
overrides: &BootstrapOverrides,
) -> anyhow::Result<bool> {
if let Some(env_value) = read_env_bool(ENV_CONTAINER_DEFAULTS)? {
return Ok(env_value);
}
if let Some(cli_value) = overrides.container_defaults {
return Ok(cli_value);
}
Ok(in_container)
}
/// Apply default values and return a fully populated config.
fn finalize_config(overrides: ConfigOverrides, container_defaults: bool) -> anyhow::Result<Config> {
let base_url = overrides
.potatomesh
.base_url
.ok_or_else(|| anyhow::anyhow!("potatomesh.base_url is required"))?;
let poll_interval_secs = overrides.potatomesh.poll_interval_secs.unwrap_or({
if container_defaults {
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
} else {
DEFAULT_POLL_INTERVAL_SECS
}
});
let homeserver = overrides
.matrix
.homeserver
.ok_or_else(|| anyhow::anyhow!("matrix.homeserver is required"))?;
let as_token = overrides
.matrix
.as_token
.ok_or_else(|| anyhow::anyhow!("matrix.as_token is required"))?;
let server_name = overrides
.matrix
.server_name
.ok_or_else(|| anyhow::anyhow!("matrix.server_name is required"))?;
let room_id = overrides
.matrix
.room_id
.ok_or_else(|| anyhow::anyhow!("matrix.room_id is required"))?;
let state_file = overrides.state.state_file.unwrap_or_else(|| {
if container_defaults {
DEFAULT_CONTAINER_STATE_FILE.to_string()
} else {
DEFAULT_STATE_FILE.to_string()
}
});
Ok(Config {
potatomesh: PotatomeshConfig {
base_url,
poll_interval_secs,
},
matrix: MatrixConfig {
homeserver,
as_token,
server_name,
room_id,
},
state: StateConfig { state_file },
})
}
impl Config {
/// Load config from a specific path.
#[allow(dead_code)]
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)
}
/// Load config from the default path in the working directory.
#[allow(dead_code)]
pub fn from_default_path() -> anyhow::Result<Self> {
let path = "Config.toml";
let path = DEFAULT_CONFIG_PATH;
if !Path::new(path).exists() {
anyhow::bail!("Config file {path} not found");
}
Self::load_from_file(path)
}
/// Load configuration by merging TOML, CLI, env, and secret values.
pub fn load_with_overrides(overrides: BootstrapOverrides) -> anyhow::Result<ConfigBootstrap> {
let in_container = detect_container();
let container_defaults = resolve_container_defaults(in_container, &overrides)?;
let config_path = resolve_config_path(container_defaults, &overrides);
let secrets_dir = resolve_secrets_dir(container_defaults);
let mut merged = ConfigOverrides::default();
if let Some(file_overrides) = load_optional_config(&config_path)? {
merged.merge(file_overrides);
} else {
tracing::warn!(
"Config file {} not found; continuing with overrides",
config_path
);
}
merged.merge(overrides.values);
merged.merge(env_overrides()?);
merged.merge(secret_overrides(secrets_dir.as_deref())?);
let config = finalize_config(merged, container_defaults)?;
let context = RuntimeContext {
in_container,
container_defaults,
config_path,
secrets_dir,
};
Ok(ConfigBootstrap { config, context })
}
}
#[cfg(test)]
@@ -62,6 +536,44 @@ mod tests {
use super::*;
use serial_test::serial;
use std::io::Write;
use std::{env, path::PathBuf};
struct EnvGuard {
key: String,
value: Option<String>,
}
impl EnvGuard {
fn set<K: Into<String>>(key: K, value: &str) -> Self {
let key = key.into();
let previous = env::var(&key).ok();
env::set_var(&key, value);
Self {
key,
value: previous,
}
}
fn unset<K: Into<String>>(key: K) -> Self {
let key = key.into();
let previous = env::var(&key).ok();
env::remove_var(&key);
Self {
key,
value: previous,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(value) = &self.value {
env::set_var(&self.key, value);
} else {
env::remove_var(&self.key);
}
}
}
#[test]
fn parse_minimal_config_from_toml_str() {
@@ -154,4 +666,190 @@ mod tests {
let result = Config::from_default_path();
assert!(result.is_ok());
}
#[test]
fn detect_container_from_env_values() {
assert!(detect_container_from(Some("1"), None));
assert!(detect_container_from(Some("true"), None));
assert!(!detect_container_from(Some("0"), None));
assert!(!detect_container_from(Some("false"), None));
assert!(!detect_container_from(Some("FALSE"), None));
}
#[test]
fn detect_container_from_cgroup_markers() {
let cgroup = "12:memory:/docker/abcd\n11:pids:/kubepods.slice";
assert!(detect_container_from(None, Some(cgroup)));
let host_cgroup = "0::/user.slice/user-1000.slice";
assert!(!detect_container_from(None, Some(host_cgroup)));
}
#[test]
#[serial]
fn env_overrides_cli_and_toml() {
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_poll = EnvGuard::set(ENV_POTATOMESH_POLL_INTERVAL, "25");
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let toml_str = r#"
[potatomesh]
base_url = "https://toml.example/"
poll_interval_secs = 10
[matrix]
homeserver = "https://matrix.example.org"
as_token = "toml-token"
server_name = "example.org"
room_id = "!roomid:example.org"
[state]
state_file = "toml_state.json"
"#;
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{}", toml_str).unwrap();
let overrides = BootstrapOverrides {
config_path: Some(file.path().to_str().unwrap().to_string()),
container_defaults: Some(false),
values: ConfigOverrides {
potatomesh: PotatomeshOverrides {
base_url: Some("https://cli.example/".to_string()),
poll_interval_secs: Some(15),
},
matrix: MatrixOverrides {
as_token: Some("cli-token".to_string()),
..Default::default()
},
state: StateOverrides {
state_file: Some("cli_state.json".to_string()),
},
},
};
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.potatomesh.base_url, "https://env.example/");
assert_eq!(result.config.potatomesh.poll_interval_secs, 25);
assert_eq!(result.config.matrix.as_token, "env-token");
assert_eq!(result.config.state.state_file, "cli_state.json");
}
#[test]
#[serial]
fn secret_file_overrides_env_values() {
let _guard_env = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_homeserver = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let _guard_env_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_container = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let secret_file = tempfile::NamedTempFile::new().unwrap();
fs::write(secret_file.path(), "secret-token").unwrap();
let _guard_secret = EnvGuard::set(
format!("{}_FILE", ENV_MATRIX_AS_TOKEN),
secret_file.path().to_str().unwrap(),
);
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.matrix.as_token, "secret-token");
}
#[test]
#[serial]
fn container_defaults_change_paths_and_intervals() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::unset(ENV_CONTAINER_DEFAULTS);
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert!(result.context.in_container);
assert!(result.context.container_defaults);
assert_eq!(result.context.config_path, DEFAULT_CONTAINER_CONFIG_PATH);
assert_eq!(result.config.state.state_file, DEFAULT_CONTAINER_STATE_FILE);
assert_eq!(
result.config.potatomesh.poll_interval_secs,
DEFAULT_CONTAINER_POLL_INTERVAL_SECS
);
}
#[test]
#[serial]
fn container_defaults_can_be_disabled() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "0");
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_token = EnvGuard::set(ENV_MATRIX_AS_TOKEN, "env-token");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert!(result.context.in_container);
assert!(!result.context.container_defaults);
assert_eq!(result.context.config_path, DEFAULT_CONFIG_PATH);
assert_eq!(result.config.state.state_file, DEFAULT_STATE_FILE);
assert_eq!(
result.config.potatomesh.poll_interval_secs,
DEFAULT_POLL_INTERVAL_SECS
);
}
#[test]
#[serial]
fn secrets_dir_defaults_are_used_when_present() {
let _guard_container = EnvGuard::set(ENV_CONTAINER, "1");
let _guard_defaults = EnvGuard::set(ENV_CONTAINER_DEFAULTS, "1");
let _guard_base = EnvGuard::set(ENV_POTATOMESH_BASE_URL, "https://env.example/");
let _guard_home = EnvGuard::set(ENV_MATRIX_HOMESERVER, "https://matrix.example.org");
let _guard_server = EnvGuard::set(ENV_MATRIX_SERVER_NAME, "example.org");
let _guard_room = EnvGuard::set(ENV_MATRIX_ROOM_ID, "!roomid:example.org");
let temp_dir = tempfile::tempdir().unwrap();
let secret_path = temp_dir.path().join(ENV_MATRIX_AS_TOKEN);
fs::write(&secret_path, "dir-token").unwrap();
let _guard_dir = EnvGuard::set(ENV_SECRETS_DIR, temp_dir.path().to_str().unwrap());
let overrides = BootstrapOverrides::default();
let result = Config::load_with_overrides(overrides).unwrap();
assert_eq!(result.config.matrix.as_token, "dir-token");
assert_eq!(
result.context.secrets_dir,
Some(PathBuf::from(temp_dir.path()))
);
}
#[test]
#[serial]
fn read_env_bool_rejects_invalid_values() {
let _guard = EnvGuard::set("POTATOMESH_TEST_BOOL", "maybe");
let result = read_env_bool("POTATOMESH_TEST_BOOL");
assert!(result.is_err());
}
#[test]
#[serial]
fn read_env_u64_rejects_invalid_values() {
let _guard = EnvGuard::set("POTATOMESH_TEST_U64", "not-a-number");
let result = read_env_u64("POTATOMESH_TEST_U64");
assert!(result.is_err());
}
#[test]
fn read_secret_file_rejects_empty_contents() {
let file = tempfile::NamedTempFile::new().unwrap();
fs::write(file.path(), " ").unwrap();
let result = read_secret_file(file.path());
assert!(result.is_err());
}
}
+345 -117
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
mod cli;
mod config;
mod matrix;
mod potatomesh;
@@ -19,16 +20,34 @@ mod potatomesh;
use std::{fs, path::Path};
use anyhow::Result;
use clap::Parser;
use tokio::time::{sleep, Duration};
use tracing::{error, info};
use crate::cli::Cli;
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage, PotatoNode};
fn format_runtime_context(context: &config::RuntimeContext) -> String {
format!(
"Runtime context: in_container={} container_defaults={} config_path={} secrets_dir={:?}",
context.in_container, context.container_defaults, context.config_path, context.secrets_dir
)
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct BridgeState {
/// Highest message id processed by the bridge.
last_message_id: Option<u64>,
/// Highest rx_time observed; used to build incremental fetch queries.
#[serde(default)]
last_rx_time: Option<u64>,
/// Message ids seen at the current last_rx_time for de-duplication.
#[serde(default)]
last_rx_time_ids: Vec<u64>,
/// Legacy checkpoint timestamp used before last_rx_time was added.
#[serde(default, skip_serializing)]
last_checked_at: Option<u64>,
}
@@ -38,7 +57,15 @@ impl BridgeState {
return Ok(Self::default());
}
let data = fs::read_to_string(path)?;
let s: Self = serde_json::from_str(&data)?;
// Treat empty/whitespace-only files as a fresh state.
if data.trim().is_empty() {
return Ok(Self::default());
}
let mut s: Self = serde_json::from_str(&data)?;
if s.last_rx_time.is_none() {
s.last_rx_time = s.last_checked_at;
}
s.last_checked_at = None;
Ok(s)
}
@@ -49,17 +76,32 @@ impl BridgeState {
}
fn should_forward(&self, msg: &PotatoMessage) -> bool {
match self.last_message_id {
None => true,
Some(last) => msg.id > last,
match self.last_rx_time {
None => match self.last_message_id {
None => true,
Some(last_id) => msg.id > last_id,
},
Some(last_ts) => {
if msg.rx_time > last_ts {
true
} else if msg.rx_time < last_ts {
false
} else {
!self.last_rx_time_ids.contains(&msg.id)
}
}
}
}
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),
});
self.last_message_id = Some(msg.id);
if self.last_rx_time.is_none() || Some(msg.rx_time) > self.last_rx_time {
self.last_rx_time = Some(msg.rx_time);
self.last_rx_time_ids = vec![msg.id];
} else if Some(msg.rx_time) == self.last_rx_time && !self.last_rx_time_ids.contains(&msg.id)
{
self.last_rx_time_ids.push(msg.id);
}
}
}
@@ -69,7 +111,7 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
limit: None,
since: None,
}
} else if let Some(ts) = state.last_checked_at {
} else if let Some(ts) = state.last_rx_time {
FetchParams {
limit: None,
since: Some(ts),
@@ -82,34 +124,18 @@ fn build_fetch_params(state: &BridgeState) -> FetchParams {
}
}
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
if !delivered_all {
return false;
}
if state.last_message_id.is_some() {
state.last_checked_at = Some(now_secs);
true
} else {
false
}
}
async fn poll_once(
potato: &PotatoClient,
matrix: &MatrixAppserviceClient,
state: &mut BridgeState,
state_path: &str,
now_secs: u64,
) {
let params = build_fetch_params(state);
match potato.fetch_messages(params).await {
Ok(mut msgs) => {
// sort by id ascending so we process in order
msgs.sort_by_key(|m| m.id);
let mut delivered_all = true;
// sort by rx_time so we process by actual receipt time
msgs.sort_by_key(|m| m.rx_time);
for msg in &msgs {
if !state.should_forward(msg) {
@@ -120,28 +146,24 @@ async fn poll_once(
if let Some(port) = &msg.portnum {
if port != "TEXT_MESSAGE_APP" {
state.update_with(msg);
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
continue;
}
}
if let Err(e) = handle_message(potato, matrix, state, msg).await {
error!("Error handling message {}: {:?}", msg.id, e);
delivered_all = false;
continue;
}
state.update_with(msg);
// persist after each processed message
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
// Only advance checkpoint after successful delivery and a known last_message_id.
if update_checkpoint(state, delivered_all, now_secs) {
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
}
Err(e) => {
error!("Error fetching PotatoMesh messages: {:?}", e);
@@ -160,8 +182,12 @@ async fn main() -> Result<()> {
)
.init();
let cfg = Config::from_default_path()?;
info!("Loaded config: {:?}", cfg);
let cli = Cli::parse();
let bootstrap = Config::load_with_overrides(cli.into_overrides())?;
info!("Loaded config: {:?}", bootstrap.config);
info!("{}", format_runtime_context(&bootstrap.context));
let cfg = bootstrap.config;
let http = reqwest::Client::builder().build()?;
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
@@ -176,12 +202,7 @@ async fn main() -> Result<()> {
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
loop {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
poll_once(&potato, &matrix, &mut state, state_path).await;
sleep(poll_interval).await;
}
@@ -199,38 +220,77 @@ async fn handle_message(
// Ensure puppet exists & has display name
matrix.ensure_user_registered(&localpart).await?;
matrix.set_display_name(&user_id, &node.long_name).await?;
matrix.ensure_user_joined_room(&user_id).await?;
let display_name = display_name_for_node(&node);
matrix.set_display_name(&user_id, &display_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}, {snr}, {chan}/{preset})",
short = short,
text = msg.text,
from_id = msg.from_id,
to_id = msg.to_id,
rssi = msg
.rssi
.map(|v| format!("RSSI {v} dB"))
.unwrap_or_else(|| "RSSI n/a".to_string()),
snr = msg
.snr
.map(|v| format!("SNR {v} dB"))
.unwrap_or_else(|| "SNR n/a".to_string()),
chan = msg.channel_name,
preset = msg.modem_preset,
let preset_short = modem_preset_short(&msg.modem_preset);
let prefix = format!(
"[{freq}][{preset_short}][{channel}]",
freq = msg.lora_freq,
preset_short = preset_short,
channel = msg.channel_name,
);
let (body, formatted_body) = format_message_bodies(&prefix, &msg.text);
matrix.send_text_message_as(&user_id, &body).await?;
matrix
.send_formatted_message_as(&user_id, &body, &formatted_body)
.await?;
state.update_with(msg);
Ok(())
}
/// Build a compact modem preset label like "LF" for "LongFast".
fn modem_preset_short(preset: &str) -> String {
let letters: String = preset
.chars()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
if letters.is_empty() {
preset.chars().take(2).collect()
} else {
letters
}
}
/// Build plain text + HTML message bodies with inline-code metadata.
fn format_message_bodies(prefix: &str, text: &str) -> (String, String) {
let body = format!("`{}` {}", prefix, text);
let formatted_body = format!("<code>{}</code> {}", escape_html(prefix), escape_html(text));
(body, formatted_body)
}
/// Build the Matrix display name from a node's long/short names.
fn display_name_for_node(node: &PotatoNode) -> String {
match node
.short_name
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(short) if short != node.long_name => format!("{} ({})", node.long_name, short),
_ => node.long_name.clone(),
}
}
/// Minimal HTML escaping for Matrix formatted_body payloads.
fn escape_html(input: &str) -> String {
let mut escaped = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&#39;"),
_ => escaped.push(ch),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
@@ -259,6 +319,54 @@ mod tests {
}
}
fn sample_node(short_name: Option<&str>, long_name: &str) -> PotatoNode {
PotatoNode {
node_id: "!abcd1234".to_string(),
short_name: short_name.map(str::to_string),
long_name: long_name.to_string(),
role: None,
hw_model: None,
last_heard: None,
first_heard: None,
latitude: None,
longitude: None,
altitude: None,
}
}
#[test]
fn modem_preset_short_handles_camelcase() {
assert_eq!(modem_preset_short("LongFast"), "LF");
assert_eq!(modem_preset_short("MediumFast"), "MF");
}
#[test]
fn format_message_bodies_escape_html() {
let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>");
assert_eq!(body, "`[868][LF]` Hello <&>");
assert_eq!(formatted, "<code>[868][LF]</code> Hello &lt;&amp;&gt;");
}
#[test]
fn escape_html_escapes_quotes() {
assert_eq!(escape_html("a\"b'c"), "a&quot;b&#39;c");
}
#[test]
fn display_name_for_node_includes_short_when_present() {
let node = sample_node(Some("TN"), "Test Node");
assert_eq!(display_name_for_node(&node), "Test Node (TN)");
}
#[test]
fn display_name_for_node_ignores_empty_or_duplicate_short() {
let empty_short = sample_node(Some(""), "Test Node");
assert_eq!(display_name_for_node(&empty_short), "Test Node");
let duplicate_short = sample_node(Some("Test Node"), "Test Node");
assert_eq!(display_name_for_node(&duplicate_short), "Test Node");
}
#[test]
fn bridge_state_initially_forwards_all() {
let state = BridgeState::default();
@@ -268,39 +376,72 @@ mod tests {
}
#[test]
fn bridge_state_tracks_highest_id_and_skips_older() {
fn bridge_state_tracks_latest_rx_time_and_skips_older() {
let mut state = BridgeState::default();
let m1 = sample_msg(10);
let m2 = sample_msg(20);
let m3 = sample_msg(15);
let m1 = PotatoMessage { rx_time: 10, ..m1 };
let m2 = PotatoMessage { rx_time: 20, ..m2 };
let m3 = PotatoMessage { rx_time: 15, ..m3 };
// First message, should forward
assert!(state.should_forward(&m1));
state.update_with(&m1);
assert_eq!(state.last_message_id, Some(10));
assert_eq!(state.last_rx_time, 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));
assert_eq!(state.last_rx_time, 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));
assert_eq!(state.last_rx_time, Some(20));
}
#[test]
fn bridge_state_update_is_monotonic() {
let mut state = BridgeState {
last_message_id: Some(50),
fn bridge_state_uses_legacy_id_filter_when_rx_time_missing() {
let state = BridgeState {
last_message_id: Some(10),
last_rx_time: None,
last_rx_time_ids: vec![],
last_checked_at: None,
};
let m = sample_msg(40);
let older = sample_msg(9);
let newer = sample_msg(11);
state.update_with(&m); // id is lower than current
// last_message_id must stay at 50
assert_eq!(state.last_message_id, Some(50));
assert!(!state.should_forward(&older));
assert!(state.should_forward(&newer));
}
#[test]
fn bridge_state_dedupes_same_timestamp() {
let mut state = BridgeState::default();
let m1 = PotatoMessage {
rx_time: 100,
..sample_msg(10)
};
let m2 = PotatoMessage {
rx_time: 100,
..sample_msg(9)
};
let dup = PotatoMessage {
rx_time: 100,
..sample_msg(10)
};
assert!(state.should_forward(&m1));
state.update_with(&m1);
assert!(state.should_forward(&m2));
state.update_with(&m2);
assert!(!state.should_forward(&dup));
assert_eq!(state.last_rx_time, Some(100));
assert_eq!(state.last_rx_time_ids, vec![10, 9]);
}
#[test]
@@ -311,13 +452,17 @@ mod tests {
let state = BridgeState {
last_message_id: Some(12345),
last_checked_at: Some(99),
last_rx_time: Some(99),
last_rx_time_ids: vec![123],
last_checked_at: Some(77),
};
state.save(path_str).unwrap();
let loaded_state = BridgeState::load(path_str).unwrap();
assert_eq!(loaded_state.last_message_id, Some(12345));
assert_eq!(loaded_state.last_checked_at, Some(99));
assert_eq!(loaded_state.last_rx_time, Some(99));
assert_eq!(loaded_state.last_rx_time_ids, vec![123]);
assert_eq!(loaded_state.last_checked_at, None);
}
#[test]
@@ -328,50 +473,50 @@ mod tests {
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
assert_eq!(state.last_rx_time, None);
assert!(state.last_rx_time_ids.is_empty());
}
#[test]
fn bridge_state_load_empty_file() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("empty.json");
let path_str = file_path.to_str().unwrap();
fs::write(path_str, "").unwrap();
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
assert_eq!(state.last_rx_time, None);
assert!(state.last_rx_time_ids.is_empty());
assert_eq!(state.last_checked_at, None);
}
#[test]
fn update_checkpoint_requires_last_message_id() {
let mut state = BridgeState {
last_message_id: None,
last_checked_at: Some(10),
};
fn bridge_state_migrates_legacy_checkpoint() {
let tmp_dir = tempfile::tempdir().unwrap();
let file_path = tmp_dir.path().join("legacy_state.json");
let path_str = file_path.to_str().unwrap();
let saved = update_checkpoint(&mut state, true, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
fs::write(
path_str,
r#"{"last_message_id":42,"last_checked_at":1710000000}"#,
)
.unwrap();
#[test]
fn update_checkpoint_skips_when_not_delivered() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: Some(10),
};
let saved = update_checkpoint(&mut state, false, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
#[test]
fn update_checkpoint_sets_when_safe() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: None,
};
let saved = update_checkpoint(&mut state, true, 123);
assert!(saved);
assert_eq!(state.last_checked_at, Some(123));
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, Some(42));
assert_eq!(state.last_rx_time, Some(1_710_000_000));
assert!(state.last_rx_time_ids.is_empty());
}
#[test]
fn fetch_params_respects_missing_last_message_id() {
let state = BridgeState {
last_message_id: None,
last_checked_at: Some(123),
last_rx_time: Some(123),
last_rx_time_ids: vec![],
last_checked_at: None,
};
let params = build_fetch_params(&state);
@@ -383,7 +528,9 @@ mod tests {
fn fetch_params_uses_since_when_safe() {
let state = BridgeState {
last_message_id: Some(1),
last_checked_at: Some(123),
last_rx_time: Some(123),
last_rx_time_ids: vec![],
last_checked_at: None,
};
let params = build_fetch_params(&state);
@@ -395,6 +542,8 @@ mod tests {
fn fetch_params_defaults_to_small_window() {
let state = BridgeState {
last_message_id: Some(1),
last_rx_time: None,
last_rx_time_ids: vec![],
last_checked_at: None,
};
@@ -404,7 +553,7 @@ mod tests {
}
#[tokio::test]
async fn poll_once_persists_checkpoint_without_messages() {
async fn poll_once_leaves_state_unchanged_without_messages() {
let tmp_dir = tempfile::tempdir().unwrap();
let state_path = tmp_dir.path().join("state.json");
let state_str = state_path.to_str().unwrap();
@@ -435,18 +584,62 @@ mod tests {
let mut state = BridgeState {
last_message_id: Some(1),
last_rx_time: Some(100),
last_rx_time_ids: vec![1],
last_checked_at: None,
};
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
poll_once(&potato, &matrix, &mut state, state_str).await;
mock_msgs.assert();
// Should have advanced checkpoint and saved it.
assert_eq!(state.last_checked_at, Some(123));
// No new data means state remains unchanged and is not persisted.
assert_eq!(state.last_rx_time, Some(100));
assert_eq!(state.last_rx_time_ids, vec![1]);
assert!(!state_path.exists());
}
#[tokio::test]
async fn poll_once_persists_state_for_non_text_messages() {
let tmp_dir = tempfile::tempdir().unwrap();
let state_path = tmp_dir.path().join("state.json");
let state_str = state_path.to_str().unwrap();
let mut server = mockito::Server::new_async().await;
let mock_msgs = server
.mock("GET", "/api/messages")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"[{"id":1,"rx_time":100,"rx_iso":"2025-11-27T00:00:00Z","from_id":"!abcd1234","to_id":"^all","channel":1,"portnum":"POSITION_APP","text":"","rssi":-100,"hop_limit":1,"lora_freq":868,"modem_preset":"MediumFast","channel_name":"TEST","snr":0.0,"node_id":"!abcd1234"}]"#,
)
.create();
let http_client = reqwest::Client::new();
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 potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
let mut state = BridgeState::default();
poll_once(&potato, &matrix, &mut state, state_str).await;
mock_msgs.assert();
assert!(state_path.exists());
let loaded = BridgeState::load(state_str).unwrap();
assert_eq!(loaded.last_checked_at, Some(123));
assert_eq!(loaded.last_message_id, Some(1));
assert_eq!(loaded.last_rx_time, Some(100));
assert_eq!(loaded.last_rx_time_ids, vec![1]);
}
#[tokio::test]
@@ -467,6 +660,8 @@ mod tests {
let node_id = "abcd1234";
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
let encoded_user = urlencoding::encode(&user_id);
let room_id = matrix_cfg.room_id.clone();
let encoded_room = urlencoding::encode(&room_id);
let mock_get_node = server
.mock("GET", "/api/nodes/abcd1234")
@@ -481,19 +676,29 @@ mod tests {
.with_status(200)
.create();
let mock_join = server
.mock(
"POST",
format!("/_matrix/client/v3/rooms/{}/join", encoded_room).as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.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())
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"displayname": "Test Node (TN)"
})))
.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);
@@ -508,6 +713,12 @@ mod tests {
.as_str(),
)
.match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str())
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"msgtype": "m.text",
"body": "`[868][MF][TEST]` Ping",
"format": "org.matrix.custom.html",
"formatted_body": "<code>[868][MF][TEST]</code> Ping",
})))
.with_status(200)
.create();
@@ -520,9 +731,26 @@ mod tests {
assert!(result.is_ok());
mock_get_node.assert();
mock_register.assert();
mock_join.assert();
mock_display_name.assert();
mock_send.assert();
assert_eq!(state.last_message_id, Some(100));
}
#[test]
fn format_runtime_context_includes_flags() {
let context = config::RuntimeContext {
in_container: true,
container_defaults: false,
config_path: "/app/Config.toml".to_string(),
secrets_dir: Some(std::path::PathBuf::from("/run/secrets")),
};
let rendered = format_runtime_context(&context);
assert!(rendered.contains("in_container=true"));
assert!(rendered.contains("container_defaults=false"));
assert!(rendered.contains("/app/Config.toml"));
assert!(rendered.contains("/run/secrets"));
}
}
+89 -25
View File
@@ -134,12 +134,50 @@ impl MatrixAppserviceClient {
}
}
/// 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<()> {
/// Ensure the puppet user is joined to the configured room.
pub async fn ensure_user_joined_room(&self, user_id: &str) -> anyhow::Result<()> {
#[derive(Serialize)]
struct JoinReq {}
let encoded_room = urlencoding::encode(&self.cfg.room_id);
let encoded_user = urlencoding::encode(user_id);
let url = format!(
"{}/_matrix/client/v3/rooms/{}/join?user_id={}&{}",
self.cfg.homeserver,
encoded_room,
encoded_user,
self.auth_query()
);
let resp = self.http.post(&url).json(&JoinReq {}).send().await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body_snip = resp.text().await.unwrap_or_default();
Err(anyhow::anyhow!(
"Matrix join failed for {} in {} with status {} ({})",
user_id,
self.cfg.room_id,
status,
body_snip
))
}
}
/// Send a text message with HTML formatting into the configured room as puppet user_id.
pub async fn send_formatted_message_as(
&self,
user_id: &str,
body_text: &str,
formatted_body: &str,
) -> anyhow::Result<()> {
#[derive(Serialize)]
struct MsgContent<'a> {
msgtype: &'a str,
body: &'a str,
format: &'a str,
formatted_body: &'a str,
}
let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst);
@@ -158,24 +196,23 @@ impl MatrixAppserviceClient {
let content = MsgContent {
msgtype: "m.text",
body: body_text,
format: "org.matrix.custom.html",
formatted_body,
};
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: {}",
"Failed to send formatted 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,
@@ -358,40 +395,59 @@ mod tests {
}
#[tokio::test]
async fn test_send_text_message_as_success() {
async fn test_ensure_user_joined_room_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 path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
let mock = server
.mock("PUT", path.as_str())
.mock("POST", path.as_str())
.match_query(query.as_str())
.with_status(200)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_joined_room(user_id).await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_send_text_message_as_fail() {
async fn test_ensure_user_joined_room_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 query = format!("user_id={}&access_token=AS_TOKEN", encoded_user);
let path = format!("/_matrix/client/v3/rooms/{}/join", encoded_room);
let mock = server
.mock("POST", path.as_str())
.match_query(query.as_str())
.with_status(403)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
cfg.room_id = room_id.to_string();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.ensure_user_joined_room(user_id).await;
mock.assert();
assert!(result.is_err());
}
#[tokio::test]
async fn test_send_formatted_message_as_success() {
let mut server = mockito::Server::new_async().await;
let user_id = "@test:example.org";
let room_id = "!roomid:example.org";
@@ -414,12 +470,20 @@ mod tests {
let mock = server
.mock("PUT", path.as_str())
.match_query(query.as_str())
.with_status(500)
.match_body(mockito::Matcher::PartialJson(serde_json::json!({
"msgtype": "m.text",
"body": "`[meta]` hello",
"format": "org.matrix.custom.html",
"formatted_body": "<code>[meta]</code> hello",
})))
.with_status(200)
.create();
let result = client.send_text_message_as(user_id, "hello").await;
let result = client
.send_formatted_message_as(user_id, "`[meta]` hello", "<code>[meta]</code> hello")
.await;
mock.assert();
assert!(result.is_err());
assert!(result.is_ok());
}
}
+13 -9
View File
@@ -213,7 +213,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to narrow results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted node rows suitable for API responses.
def query_nodes(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -221,7 +221,8 @@ module PotatoMesh
db.results_as_hash = true
now = Time.now.to_i
min_last_heard = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_last_heard)
since_floor = node_ref ? 0 : min_last_heard
since_threshold = normalize_since_threshold(since, floor: since_floor)
params = []
where_clauses = []
@@ -283,7 +284,7 @@ module PotatoMesh
# Fetch ingestor heartbeats with optional freshness filtering.
#
# @param limit [Integer] maximum number of ingestors to return.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted ingestor rows suitable for API responses.
def query_ingestors(limit, since: 0)
limit = coerce_query_limit(limit)
@@ -422,7 +423,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, position_time, 0) >= ?"
params << since_threshold
@@ -470,7 +472,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted neighbor rows suitable for API responses.
def query_neighbors(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -480,7 +482,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
@@ -517,7 +520,7 @@ module PotatoMesh
#
# @param limit [Integer] maximum number of rows to return.
# @param node_ref [String, Integer, nil] optional node reference to scope results.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window.
# @param since [Integer] unix timestamp threshold applied in addition to the rolling window for collections.
# @return [Array<Hash>] compacted telemetry rows suitable for API responses.
def query_telemetry(limit, node_ref: nil, since: 0)
limit = coerce_query_limit(limit)
@@ -527,7 +530,8 @@ module PotatoMesh
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
since_floor = node_ref ? 0 : min_rx_time
since_threshold = normalize_since_threshold(since, floor: since_floor)
where_clauses << "COALESCE(rx_time, telemetry_time, 0) >= ?"
params << since_threshold
@@ -734,7 +738,7 @@ module PotatoMesh
params = []
where_clauses = []
now = Time.now.to_i
min_rx_time = now - PotatoMesh::Config.week_seconds
min_rx_time = now - PotatoMesh::Config.trace_neighbor_window_seconds
since_threshold = normalize_since_threshold(since, floor: min_rx_time)
where_clauses << "COALESCE(rx_time, 0) >= ?"
params << since_threshold
+7
View File
@@ -158,6 +158,13 @@ module PotatoMesh
7 * 24 * 60 * 60
end
# Rolling retention window in seconds for trace and neighbor API queries.
#
# @return [Integer] seconds in twenty-eight days.
def trace_neighbor_window_seconds
28 * 24 * 60 * 60
end
# Default upper bound for accepted JSON payload sizes.
#
# @return [Integer] byte ceiling for HTTP request bodies.
-10
View File
@@ -7,10 +7,6 @@
"": {
"name": "potato-mesh",
"version": "0.5.9",
"hasInstallScript": true,
"dependencies": {
"uplot": "^1.6.30"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -158,12 +154,6 @@
"node": ">=8"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
-4
View File
@@ -4,12 +4,8 @@
"type": "module",
"private": true,
"scripts": {
"postinstall": "node ./scripts/copy-uplot.js",
"test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js"
},
"dependencies": {
"uplot": "^1.6.30"
},
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -80,19 +80,13 @@ test('initializeChartsPage renders the telemetry charts when snapshots are avail
},
]);
let receivedOptions = null;
let mountedModels = null;
const createCharts = (node, options) => {
const renderCharts = (node, options) => {
receivedOptions = options;
return { chartsHtml: '<section class="node-detail__charts">Charts</section>', chartModels: [{ id: 'power' }] };
return '<section class="node-detail__charts">Charts</section>';
};
const mountCharts = (chartModels, options) => {
mountedModels = { chartModels, options };
return [];
};
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts, mountCharts });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('node-detail__charts'), true);
assert.equal(mountedModels.chartModels.length, 1);
assert.ok(receivedOptions);
assert.equal(receivedOptions.chartOptions.windowMs, 604_800_000);
assert.equal(typeof receivedOptions.chartOptions.lineReducer, 'function');
@@ -124,8 +118,8 @@ test('initializeChartsPage shows an error message when fetching fails', async ()
const fetchImpl = async () => {
throw new Error('network');
};
const createCharts = () => ({ chartsHtml: '<section>unused</section>', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
const renderCharts = () => '<section>unused</section>';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, false);
assert.equal(container.innerHTML.includes('Failed to load telemetry charts.'), true);
});
@@ -142,8 +136,8 @@ test('initializeChartsPage handles missing containers and empty telemetry snapsh
},
};
const fetchImpl = async () => createResponse(200, []);
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
@@ -161,8 +155,8 @@ test('initializeChartsPage shows a status when rendering produces no markup', as
aggregates: { voltage: { avg: 3.9 } },
},
]);
const createCharts = () => ({ chartsHtml: '', chartModels: [] });
const result = await initializeChartsPage({ document: documentStub, fetchImpl, createCharts });
const renderCharts = () => '';
const result = await initializeChartsPage({ document: documentStub, fetchImpl, renderCharts });
assert.equal(result, true);
assert.equal(container.innerHTML.includes('Telemetry snapshots are unavailable.'), true);
});
@@ -113,11 +113,9 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
});
test('buildChatTabModel always includes channel zero bucket', () => {
test('buildChatTabModel skips channel buckets when there are no messages', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[0].entries.length, 0);
assert.equal(model.channels.length, 0);
});
test('buildChatTabModel falls back to numeric label when no metadata provided', () => {
@@ -0,0 +1,455 @@
/*
* 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 { __test__, initializeMobileMenu } from '../mobile-menu.js';
const { createMobileMenuController, resolveFocusableElements } = __test__;
function createClassList() {
const values = new Set();
return {
add(...names) {
names.forEach(name => values.add(name));
},
remove(...names) {
names.forEach(name => values.delete(name));
},
contains(name) {
return values.has(name);
}
};
}
function createElement(tagName = 'div', initialId = '') {
const listeners = new Map();
const attributes = new Map();
if (initialId) {
attributes.set('id', String(initialId));
}
return {
tagName: tagName.toUpperCase(),
attributes,
classList: createClassList(),
dataset: {},
hidden: false,
parentNode: null,
nextSibling: null,
setAttribute(name, value) {
attributes.set(name, String(value));
},
getAttribute(name) {
return attributes.has(name) ? attributes.get(name) : null;
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchEvent(event) {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
},
appendChild(node) {
this.lastAppended = node;
return node;
},
insertBefore(node, nextSibling) {
this.lastInserted = { node, nextSibling };
return node;
},
focus() {
globalThis.document.activeElement = this;
},
querySelector() {
return null;
},
querySelectorAll() {
return [];
}
};
}
function createDomStub() {
const originalDocument = globalThis.document;
const registry = new Map();
const documentStub = {
body: createElement('body'),
activeElement: null,
querySelectorAll() {
return [];
},
getElementById(id) {
return registry.get(id) || null;
}
};
globalThis.document = documentStub;
return {
documentStub,
registry,
cleanup() {
globalThis.document = originalDocument;
}
};
}
function createWindowStub(matches = true) {
const listeners = new Map();
const mediaListeners = new Map();
return {
matchMedia() {
return {
matches,
addEventListener(event, handler) {
mediaListeners.set(event, handler);
}
};
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchEvent(event) {
const key = typeof event === 'string' ? event : event?.type;
const handler = listeners.get(key);
if (handler) {
handler(event);
}
},
dispatchMediaChange() {
const handler = mediaListeners.get('change');
if (handler) {
handler();
}
}
};
}
function createWindowStubWithListener(matches = true) {
const listeners = new Map();
let mediaHandler = null;
return {
matchMedia() {
return {
matches,
addListener(handler) {
mediaHandler = handler;
}
};
},
addEventListener(event, handler) {
listeners.set(event, handler);
},
dispatchMediaChange() {
if (mediaHandler) {
mediaHandler();
}
}
};
}
test('mobile menu toggles open state and aria-expanded', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const closeButton = createElement('button');
const navLink = createElement('a');
menu.hidden = true;
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = selector => {
if (selector === '[data-mobile-menu-close]') return [closeButton];
if (selector === 'a') return [navLink];
return [];
};
menuPanel.querySelectorAll = () => [closeButton, navLink];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
windowStub.dispatchMediaChange();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(menu.hidden, false);
assert.equal(menuToggle.getAttribute('aria-expanded'), 'true');
assert.equal(documentStub.body.classList.contains('menu-open'), true);
navLink.dispatchEvent({ type: 'click' });
assert.equal(menu.hidden, true);
closeButton.dispatchEvent({ type: 'click' });
assert.equal(menu.hidden, true);
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
} finally {
cleanup();
}
});
test('mobile menu closes on escape and route changes', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const closeButton = createElement('button');
menu.hidden = true;
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = selector => {
if (selector === '[data-mobile-menu-close]') return [closeButton];
return [];
};
menuPanel.querySelectorAll = () => [closeButton];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(menu.hidden, false);
menuPanel.dispatchEvent({ type: 'keydown', key: 'ArrowDown' });
assert.equal(menu.hidden, false);
menuPanel.dispatchEvent({ type: 'keydown', key: 'Escape', preventDefault() {} });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
windowStub.dispatchEvent({ type: 'hashchange' });
assert.equal(menu.hidden, true);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
windowStub.dispatchEvent({ type: 'popstate' });
assert.equal(menu.hidden, true);
} finally {
cleanup();
}
});
test('mobile menu traps focus within the panel', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const firstLink = createElement('a');
const lastButton = createElement('button');
menuPanel.classList.add('mobile-menu__panel');
menuPanel.querySelectorAll = () => [firstLink, lastButton];
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
documentStub.activeElement = lastButton;
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: false });
assert.equal(documentStub.activeElement, firstLink);
documentStub.activeElement = firstLink;
menuPanel.dispatchEvent({ type: 'keydown', key: 'Tab', preventDefault() {}, shiftKey: true });
assert.equal(documentStub.activeElement, lastButton);
} finally {
cleanup();
}
});
test('resolveFocusableElements filters out aria-hidden nodes', () => {
const hiddenButton = createElement('button');
hiddenButton.getAttribute = name => (name === 'aria-hidden' ? 'true' : null);
const openLink = createElement('a');
const bareNode = { tagName: 'DIV' };
const container = {
querySelectorAll() {
return [hiddenButton, bareNode, openLink];
}
};
const focusables = resolveFocusableElements(container);
assert.equal(focusables.length, 1);
assert.equal(focusables[0], openLink);
});
test('resolveFocusableElements handles empty containers', () => {
assert.deepEqual(resolveFocusableElements(null), []);
assert.deepEqual(resolveFocusableElements({}), []);
});
test('mobile menu focuses the panel when no focusables exist', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
const lastActive = createElement('button');
menuPanel.classList.add('mobile-menu__panel');
menuPanel.querySelectorAll = () => [];
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
documentStub.activeElement = lastActive;
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(documentStub.activeElement, menuPanel);
menuToggle.dispatchEvent({ type: 'click', preventDefault() {} });
assert.equal(documentStub.activeElement, lastActive);
} finally {
cleanup();
}
});
test('mobile menu registers legacy media query listeners', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStubWithListener(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
windowStub.dispatchMediaChange();
assert.equal(menuToggle.getAttribute('aria-expanded'), 'false');
} finally {
cleanup();
}
});
test('mobile menu safely no-ops without required nodes', () => {
const { documentStub, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
try {
const controller = createMobileMenuController({
documentObject: documentStub,
windowObject: windowStub
});
controller.initialize();
controller.openMenu();
controller.closeMenu();
controller.syncLayout();
assert.equal(documentStub.body.classList.contains('menu-open'), false);
} finally {
cleanup();
}
});
test('initializeMobileMenu returns a controller', () => {
const { documentStub, registry, cleanup } = createDomStub();
const windowStub = createWindowStub(true);
const menuToggle = createElement('button');
const menu = createElement('div');
const menuPanel = createElement('div');
menuPanel.classList.add('mobile-menu__panel');
menu.querySelector = selector => {
if (selector === '.mobile-menu__panel') return menuPanel;
return null;
};
menu.querySelectorAll = () => [];
registry.set('mobileMenuToggle', menuToggle);
registry.set('mobileMenu', menu);
try {
const controller = initializeMobileMenu({
documentObject: documentStub,
windowObject: windowStub
});
assert.equal(typeof controller.openMenu, 'function');
} finally {
cleanup();
}
});
@@ -111,26 +111,6 @@ test('createNodeDetailOverlayManager renders fetched markup and restores focus',
assert.equal(focusTarget.focusCalled, true);
});
test('createNodeDetailOverlayManager mounts telemetry charts for overlay content', async () => {
const { document, content } = createOverlayHarness();
const chartModels = [{ id: 'power' }];
let mountCall = null;
const manager = createNodeDetailOverlayManager({
document,
fetchNodeDetail: async () => ({ html: '<section class="node-detail">Charts</section>', chartModels }),
mountCharts: (models, options) => {
mountCall = { models, options };
return [];
},
});
assert.ok(manager);
await manager.open({ nodeId: '!alpha' });
assert.equal(content.innerHTML.includes('Charts'), true);
assert.ok(mountCall);
assert.equal(mountCall.models, chartModels);
assert.equal(mountCall.options.root, content);
});
test('createNodeDetailOverlayManager surfaces errors and supports escape closing', async () => {
const { document, overlay, content } = createOverlayHarness();
const errors = [];
@@ -47,9 +47,7 @@ const {
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
createTelemetryCharts,
renderTelemetryCharts,
buildUPlotChartConfig,
renderMessages,
renderTraceroutes,
renderTracePath,
@@ -388,10 +386,23 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
},
};
const html = renderTelemetryCharts(node, { nowMs });
const fmt = new Date(nowMs);
const expectedDate = String(fmt.getDate()).padStart(2, '0');
assert.equal(html.includes('node-detail__charts'), true);
assert.equal(html.includes('Power metrics'), true);
assert.equal(html.includes('Environmental telemetry'), true);
assert.equal(html.includes('node-detail__chart-plot'), true);
assert.equal(html.includes('Battery (%)'), true);
assert.equal(html.includes('Voltage (V)'), true);
assert.equal(html.includes('Current (A)'), true);
assert.equal(html.includes('Channel utilization (%)'), true);
assert.equal(html.includes('Air util TX (%)'), true);
assert.equal(html.includes('Utilization (%)'), true);
assert.equal(html.includes('Gas resistance (\u03a9)'), true);
assert.equal(html.includes('Air quality'), true);
assert.equal(html.includes('IAQ index'), true);
assert.equal(html.includes('Temperature (\u00b0C)'), true);
assert.equal(html.includes(expectedDate), true);
assert.equal(html.includes('node-detail__chart-point'), true);
});
test('renderTelemetryCharts expands upper bounds when overflow metrics exceed defaults', () => {
@@ -422,18 +433,12 @@ test('renderTelemetryCharts expands upper bounds when overflow metrics exceed de
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const environmentChart = chartModels.find(model => model.id === 'environment');
const airChart = chartModels.find(model => model.id === 'airQuality');
const powerConfig = buildUPlotChartConfig(powerChart);
const envConfig = buildUPlotChartConfig(environmentChart);
const airConfig = buildUPlotChartConfig(airChart);
assert.equal(powerConfig.options.scales.voltage.range()[1], 7.2);
assert.equal(powerConfig.options.scales.current.range()[1], 3.6);
assert.equal(envConfig.options.scales.temperature.range()[1], 45);
assert.equal(airConfig.options.scales.iaq.range()[1], 650);
assert.equal(airConfig.options.scales.pressure.range()[1], 1100);
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />7\.2<\/text>/);
assert.match(html, />3\.6<\/text>/);
assert.match(html, />45<\/text>/);
assert.match(html, />650<\/text>/);
assert.match(html, />1100<\/text>/);
});
test('renderTelemetryCharts keeps default bounds when metrics stay within limits', () => {
@@ -464,17 +469,11 @@ test('renderTelemetryCharts keeps default bounds when metrics stay within limits
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const environmentChart = chartModels.find(model => model.id === 'environment');
const airChart = chartModels.find(model => model.id === 'airQuality');
const powerConfig = buildUPlotChartConfig(powerChart);
const envConfig = buildUPlotChartConfig(environmentChart);
const airConfig = buildUPlotChartConfig(airChart);
assert.equal(powerConfig.options.scales.voltage.range()[1], 6);
assert.equal(powerConfig.options.scales.current.range()[1], 3);
assert.equal(envConfig.options.scales.temperature.range()[1], 40);
assert.equal(airConfig.options.scales.iaq.range()[1], 500);
const html = renderTelemetryCharts(node, { nowMs });
assert.match(html, />6\.0<\/text>/);
assert.match(html, />3\.0<\/text>/);
assert.match(html, />40<\/text>/);
assert.match(html, />500<\/text>/);
});
test('renderNodeDetailHtml composes the table, neighbors, and messages', () => {
@@ -590,18 +589,17 @@ test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
neighbors: [],
rawSources: { node: { node_id: '!alpha', role: 'CLIENT', short_name: 'ALPH' } },
});
const result = await fetchNodeDetailHtml(reference, {
const html = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
returnState: true,
});
assert.equal(calledUrls.some(url => url.includes('/api/messages/!alpha')), true);
assert.equal(calledUrls.some(url => url.includes('/api/traces/!alpha')), true);
assert.equal(result.html.includes('Example Alpha'), true);
assert.equal(result.html.includes('Overlay hello'), true);
assert.equal(result.html.includes('Traceroutes'), true);
assert.equal(result.html.includes('node-detail__table'), true);
assert.equal(html.includes('Example Alpha'), true);
assert.equal(html.includes('Overlay hello'), true);
assert.equal(html.includes('Traceroutes'), true);
assert.equal(html.includes('node-detail__table'), true);
});
test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async () => {
@@ -639,17 +637,16 @@ test('fetchNodeDetailHtml hydrates traceroute nodes with API metadata', async ()
rawSources: { node: { node_id: '!origin', role: 'CLIENT', short_name: 'ORIG' } },
});
const result = await fetchNodeDetailHtml(reference, {
const html = await fetchNodeDetailHtml(reference, {
refreshImpl,
fetchImpl,
renderShortHtml: short => `<span class="short-name">${short}</span>`,
returnState: true,
});
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!relay')), true);
assert.equal(calledUrls.some(url => url.includes('/api/nodes/!target')), true);
assert.equal(result.html.includes('RLY1'), true);
assert.equal(result.html.includes('TGT1'), true);
assert.equal(html.includes('RLY1'), true);
assert.equal(html.includes('TGT1'), true);
});
test('fetchNodeDetailHtml requires a node identifier reference', async () => {
@@ -949,13 +946,19 @@ test('initializeNodeDetailPage reports an error when refresh fails', async () =>
throw new Error('boom');
};
const renderShortHtml = short => `<span>${short}</span>`;
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
const originalError = console.error;
console.error = () => {};
try {
const result = await initializeNodeDetailPage({
document: documentStub,
refreshImpl,
renderShortHtml,
});
assert.equal(result, false);
assert.equal(element.innerHTML.includes('Failed to load'), true);
} finally {
console.error = originalError;
}
});
test('initializeNodeDetailPage handles missing reference payloads', async () => {
@@ -1,360 +0,0 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { __testUtils } from '../node-page.js';
import { buildMovingAverageSeries } from '../charts-page.js';
const {
createTelemetryCharts,
buildUPlotChartConfig,
mountTelemetryCharts,
mountTelemetryChartsWithRetry,
} = __testUtils;
test('uPlot chart config preserves axes, colors, and tick labels for node telemetry', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
battery_level: 80,
voltage: 4.1,
current: 0.75,
},
},
{
rx_time: nowSeconds - 3_600,
device_metrics: {
battery_level: 78,
voltage: 4.05,
current: 0.65,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, {
nowMs,
chartOptions: {
xAxisTickBuilder: () => [nowMs],
xAxisTickFormatter: () => '08',
},
});
const powerChart = chartModels.find(model => model.id === 'power');
const { options, data } = buildUPlotChartConfig(powerChart);
assert.deepEqual(options.scales.battery.range(), [0, 100]);
assert.deepEqual(options.scales.voltage.range(), [0, 6]);
assert.deepEqual(options.scales.current.range(), [0, 3]);
assert.equal(options.series[1].stroke, '#8856a7');
assert.equal(options.series[2].stroke, '#9ebcda');
assert.equal(options.series[3].stroke, '#3182bd');
assert.deepEqual(options.axes[0].values(null, [nowMs]), ['08']);
assert.equal(options.axes[0].stroke, '#5c6773');
assert.deepEqual(data[0].slice(0, 2), [nowMs - 3_600_000, nowMs - 60_000]);
assert.deepEqual(data[1].slice(0, 2), [78, 80]);
});
test('uPlot chart config maps moving averages and raw points for aggregated telemetry', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const snapshots = [
{
rx_time: nowSeconds - 3_600,
device_metrics: { battery_level: 10 },
},
{
rx_time: nowSeconds - 1_800,
device_metrics: { battery_level: 20 },
},
];
const node = { rawSources: { telemetry: { snapshots } } };
const { chartModels } = createTelemetryCharts(node, {
nowMs,
chartOptions: {
lineReducer: points => buildMovingAverageSeries(points, 3_600_000),
},
});
const powerChart = chartModels.find(model => model.id === 'power');
const { options, data } = buildUPlotChartConfig(powerChart);
assert.equal(options.series.length, 3);
assert.equal(options.series[1].stroke.startsWith('rgba('), true);
assert.equal(options.series[2].stroke, '#8856a7');
assert.deepEqual(data[1].slice(0, 2), [10, 15]);
assert.deepEqual(data[2].slice(0, 2), [10, 20]);
});
test('buildUPlotChartConfig applies axis color overrides', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const powerChart = chartModels.find(model => model.id === 'power');
const { options } = buildUPlotChartConfig(powerChart, {
axisColor: '#ffffff',
gridColor: '#222222',
});
assert.equal(options.axes[0].stroke, '#ffffff');
assert.equal(options.axes[0].grid.stroke, '#222222');
});
test('environment chart renders humidity axis on the right side', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
environment_metrics: {
temperature: 19.5,
relative_humidity: 55,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const envChart = chartModels.find(model => model.id === 'environment');
const { options } = buildUPlotChartConfig(envChart);
const humidityAxis = options.axes.find(axis => axis.scale === 'humidity');
assert.ok(humidityAxis);
assert.equal(humidityAxis.side, 1);
assert.equal(humidityAxis.show, true);
});
test('channel utilization chart includes a right-side utilization axis', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: {
channel_utilization: 40,
air_util_tx: 22,
},
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const channelChart = chartModels.find(model => model.id === 'channel');
const { options } = buildUPlotChartConfig(channelChart);
const rightAxis = options.axes.find(axis => axis.scale === 'channelSecondary');
assert.ok(rightAxis);
assert.equal(rightAxis.side, 1);
assert.equal(rightAxis.show, true);
});
test('createTelemetryCharts returns empty markup when snapshots are missing', () => {
const { chartsHtml, chartModels } = createTelemetryCharts({ rawSources: { telemetry: { snapshots: [] } } });
assert.equal(chartsHtml, '');
assert.equal(chartModels.length, 0);
});
test('mountTelemetryCharts instantiates uPlot for chart containers', () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = { innerHTML: 'placeholder' };
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
}
}
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
assert.equal(plotRoot.innerHTML, '');
assert.equal(instances.length, 1);
assert.equal(instances[0].container, plotRoot);
});
test('mountTelemetryCharts responds to window resize events', async () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = {
innerHTML: '',
clientWidth: 320,
clientHeight: 180,
getBoundingClientRect() {
return { width: this.clientWidth, height: this.clientHeight };
},
};
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
const previousResizeObserver = globalThis.ResizeObserver;
const previousAddEventListener = globalThis.addEventListener;
let resizeHandler = null;
globalThis.ResizeObserver = undefined;
globalThis.addEventListener = (event, handler) => {
if (event === 'resize') {
resizeHandler = handler;
}
};
const sizeCalls = [];
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
this.root = container;
}
setSize(size) {
sizeCalls.push(size);
}
}
mountTelemetryCharts(chartModels, { root, uPlotImpl: UPlotStub });
assert.ok(resizeHandler);
plotRoot.clientWidth = 480;
plotRoot.clientHeight = 240;
resizeHandler();
await new Promise(resolve => setTimeout(resolve, 150));
assert.equal(sizeCalls.length >= 1, true);
assert.deepEqual(sizeCalls[sizeCalls.length - 1], { width: 480, height: 240 });
globalThis.ResizeObserver = previousResizeObserver;
globalThis.addEventListener = previousAddEventListener;
});
test('mountTelemetryChartsWithRetry loads uPlot when missing', async () => {
const nowMs = Date.UTC(2025, 0, 8, 12, 0, 0);
const nowSeconds = Math.floor(nowMs / 1000);
const node = {
rawSources: {
telemetry: {
snapshots: [
{
rx_time: nowSeconds - 60,
device_metrics: { battery_level: 80 },
},
],
},
},
};
const { chartModels } = createTelemetryCharts(node, { nowMs });
const [model] = chartModels;
const plotRoot = { innerHTML: '', clientWidth: 400, clientHeight: 200 };
const chartContainer = {
querySelector(selector) {
return selector === '[data-telemetry-plot]' ? plotRoot : null;
},
};
const root = {
ownerDocument: {
body: {},
querySelector: () => null,
},
querySelector(selector) {
return selector === `[data-telemetry-chart-id="${model.id}"]` ? chartContainer : null;
},
};
const previousUPlot = globalThis.uPlot;
const instances = [];
class UPlotStub {
constructor(options, data, container) {
this.options = options;
this.data = data;
this.container = container;
instances.push(this);
}
}
let loadCalled = false;
const loadUPlot = ({ onLoad }) => {
loadCalled = true;
globalThis.uPlot = UPlotStub;
if (typeof onLoad === 'function') {
onLoad();
}
return true;
};
mountTelemetryChartsWithRetry(chartModels, { root, loadUPlot });
await new Promise(resolve => setTimeout(resolve, 0));
assert.equal(loadCalled, true);
assert.equal(instances.length, 1);
globalThis.uPlot = previousUPlot;
});
+5 -22
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createTelemetryCharts, mountTelemetryChartsWithRetry } from './node-page.js';
import { renderTelemetryCharts } from './node-page.js';
const TELEMETRY_BUCKET_SECONDS = 60 * 60;
const HOUR_MS = 60 * 60 * 1000;
@@ -193,21 +193,6 @@ export async function fetchAggregatedTelemetry({
.filter(snapshot => snapshot != null);
}
/**
* Fetch and render aggregated telemetry charts.
*
* @param {{
* document?: Document,
* rootId?: string,
* fetchImpl?: Function,
* bucketSeconds?: number,
* windowMs?: number,
* createCharts?: Function,
* mountCharts?: Function,
* uPlotImpl?: Function,
* }} options Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when charts were rendered successfully.
*/
export async function initializeChartsPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.getElementById !== 'function') {
@@ -219,8 +204,7 @@ export async function initializeChartsPage(options = {}) {
return false;
}
const createCharts = typeof options.createCharts === 'function' ? options.createCharts : createTelemetryCharts;
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
const renderCharts = typeof options.renderCharts === 'function' ? options.renderCharts : renderTelemetryCharts;
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
const bucketSeconds = options.bucketSeconds ?? TELEMETRY_BUCKET_SECONDS;
const windowMs = options.windowMs ?? CHART_WINDOW_MS;
@@ -234,7 +218,7 @@ export async function initializeChartsPage(options = {}) {
return true;
}
const node = { rawSources: { telemetry: { snapshots } } };
const chartState = createCharts(node, {
const chartsHtml = renderCharts(node, {
nowMs: Date.now(),
chartOptions: {
windowMs,
@@ -244,12 +228,11 @@ export async function initializeChartsPage(options = {}) {
lineReducer: points => buildMovingAverageSeries(points, HOUR_MS),
},
});
if (!chartState.chartsHtml) {
if (!chartsHtml) {
container.innerHTML = renderStatus('Telemetry snapshots are unavailable.');
return true;
}
container.innerHTML = chartState.chartsHtml;
mountCharts(chartState.chartModels, { root: container, uPlotImpl: options.uPlotImpl });
container.innerHTML = chartsHtml;
return true;
} catch (error) {
console.error('Failed to render aggregated telemetry charts', error);
+2 -21
View File
@@ -65,7 +65,8 @@ function resolveSnapshotList(entry) {
* Build a data model describing the content for chat tabs.
*
* Entries outside the recent activity window, encrypted messages, and
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
* channels above {@link MAX_CHANNEL_INDEX} are filtered out. Channel
* buckets are only created when messages are present for that channel.
*
* @param {{
* nodes?: Array<Object>,
@@ -287,26 +288,6 @@ export function buildChatTabModel({
logEntries.sort((a, b) => a.ts - b.ts);
let hasPrimaryBucket = false;
for (const bucket of channelBuckets.values()) {
if (bucket.index === 0) {
hasPrimaryBucket = true;
break;
}
}
if (!hasPrimaryBucket) {
const bucketKey = '0';
channelBuckets.set(bucketKey, {
key: bucketKey,
id: buildChannelTabId(bucketKey),
index: 0,
label: '0',
entries: [],
labelPriority: CHANNEL_LABEL_PRIORITY.INDEX,
isPrimaryFallback: true
});
}
const channels = Array.from(channelBuckets.values()).sort((a, b) => {
if (a.index !== b.index) {
return a.index - b.index;
+4 -1
View File
@@ -44,6 +44,7 @@ import {
formatChatPresetTag
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { initializeMobileMenu } from './mobile-menu.js';
import { MESSAGE_LIMIT, normaliseMessageLimit } from './message-limit.js';
import { CHAT_LOG_ENTRY_TYPES, buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
@@ -119,6 +120,8 @@ export function initializeApp(config) {
const isChatView = bodyClassList ? bodyClassList.contains('view-chat') : false;
const isMapView = bodyClassList ? bodyClassList.contains('view-map') : false;
const mapZoomOverride = Number.isFinite(config.mapZoom) ? Number(config.mapZoom) : null;
initializeMobileMenu({ documentObject: document, windowObject: window });
/**
* Column sorter configuration for the node table.
*
@@ -192,7 +195,7 @@ export function initializeApp(config) {
});
const NODE_LIMIT = 1000;
const TRACE_LIMIT = 200;
const TRACE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
const TRACE_MAX_AGE_SECONDS = 28 * 24 * 60 * 60;
const SNAPSHOT_LIMIT = SNAPSHOT_WINDOW;
const CHAT_LIMIT = MESSAGE_LIMIT;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
+271
View File
@@ -0,0 +1,271 @@
/*
* 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.
*/
const MOBILE_MENU_MEDIA_QUERY = '(max-width: 900px)';
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])'
].join(', ');
/**
* Collect the elements that can receive focus within a container.
*
* @param {?Element} container DOM node hosting focusable descendants.
* @returns {Array<Element>} Ordered list of focusable elements.
*/
function resolveFocusableElements(container) {
if (!container || typeof container.querySelectorAll !== 'function') {
return [];
}
const candidates = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
return candidates.filter(candidate => {
if (!candidate || typeof candidate.getAttribute !== 'function') {
return false;
}
return candidate.getAttribute('aria-hidden') !== 'true';
});
}
/**
* Build a menu controller for handling toggle state, focus trapping, and
* responsive layout swapping.
*
* @param {{
* documentObject?: Document,
* windowObject?: Window
* }} [options]
* @returns {{
* initialize: () => void,
* openMenu: () => void,
* closeMenu: () => void,
* syncLayout: () => void
* }}
*/
function createMobileMenuController(options = {}) {
const documentObject = options.documentObject || document;
const windowObject = options.windowObject || window;
const menuToggle = documentObject.getElementById('mobileMenuToggle');
const menu = documentObject.getElementById('mobileMenu');
const menuPanel = menu ? menu.querySelector('.mobile-menu__panel') : null;
const closeTriggers = menu ? Array.from(menu.querySelectorAll('[data-mobile-menu-close]')) : [];
const menuLinks = menu ? Array.from(menu.querySelectorAll('a')) : [];
const body = documentObject.body;
const mediaQuery = windowObject.matchMedia
? windowObject.matchMedia(MOBILE_MENU_MEDIA_QUERY)
: null;
let isOpen = false;
let lastActive = null;
/**
* Toggle the ``aria-expanded`` state on the menu trigger.
*
* @param {boolean} expanded Whether the menu is open.
* @returns {void}
*/
function setExpandedState(expanded) {
if (!menuToggle || typeof menuToggle.setAttribute !== 'function') {
return;
}
menuToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
/**
* Synchronize the meta row placement based on the active media query.
*
* @returns {void}
*/
function syncLayout() {
return;
}
/**
* Open the slide-in menu and trap focus within the panel.
*
* @returns {void}
*/
function openMenu() {
if (!menu || !menuToggle || !menuPanel) {
return;
}
syncLayout();
menu.hidden = false;
menu.classList.add('is-open');
if (body && body.classList) {
body.classList.add('menu-open');
}
setExpandedState(true);
isOpen = true;
lastActive = documentObject.activeElement || null;
const focusables = resolveFocusableElements(menuPanel);
const focusTarget = focusables[0] || menuPanel;
if (focusTarget && typeof focusTarget.focus === 'function') {
focusTarget.focus();
}
}
/**
* Close the menu and restore focus to the trigger.
*
* @returns {void}
*/
function closeMenu() {
if (!menu || !menuToggle) {
return;
}
menu.classList.remove('is-open');
menu.hidden = true;
if (body && body.classList) {
body.classList.remove('menu-open');
}
setExpandedState(false);
isOpen = false;
if (lastActive && typeof lastActive.focus === 'function') {
lastActive.focus();
}
}
/**
* Toggle open or closed based on the trigger interaction.
*
* @param {Event} event Click event originating from the trigger.
* @returns {void}
*/
function handleToggleClick(event) {
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
if (isOpen) {
closeMenu();
} else {
openMenu();
}
}
/**
* Trap tab focus within the menu panel while open.
*
* @param {KeyboardEvent} event Keydown event from the panel.
* @returns {void}
*/
function handleKeydown(event) {
if (!isOpen || !event) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
closeMenu();
return;
}
if (event.key !== 'Tab') {
return;
}
const focusables = resolveFocusableElements(menuPanel);
if (!focusables.length) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = documentObject.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
/**
* Close the menu when navigation state changes.
*
* @returns {void}
*/
function handleRouteChange() {
if (isOpen) {
closeMenu();
}
}
/**
* Attach event listeners and sync initial layout.
*
* @returns {void}
*/
function initialize() {
if (!menuToggle || !menu) {
return;
}
menuToggle.addEventListener('click', handleToggleClick);
closeTriggers.forEach(trigger => {
trigger.addEventListener('click', closeMenu);
});
menuLinks.forEach(link => {
link.addEventListener('click', closeMenu);
});
if (menuPanel && typeof menuPanel.addEventListener === 'function') {
menuPanel.addEventListener('keydown', handleKeydown);
}
if (mediaQuery) {
if (typeof mediaQuery.addEventListener === 'function') {
mediaQuery.addEventListener('change', syncLayout);
} else if (typeof mediaQuery.addListener === 'function') {
mediaQuery.addListener(syncLayout);
}
}
if (windowObject && typeof windowObject.addEventListener === 'function') {
windowObject.addEventListener('hashchange', handleRouteChange);
windowObject.addEventListener('popstate', handleRouteChange);
}
syncLayout();
setExpandedState(false);
}
return {
initialize,
openMenu,
closeMenu,
syncLayout,
};
}
/**
* Initialize the mobile menu using the live DOM environment.
*
* @param {{
* documentObject?: Document,
* windowObject?: Window
* }} [options]
* @returns {{
* initialize: () => void,
* openMenu: () => void,
* closeMenu: () => void,
* syncLayout: () => void
* }}
*/
export function initializeMobileMenu(options = {}) {
const controller = createMobileMenuController(options);
controller.initialize();
return controller;
}
export const __test__ = {
createMobileMenuController,
resolveFocusableElements,
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { fetchNodeDetailHtml, mountTelemetryChartsWithRetry } from './node-page.js';
import { fetchNodeDetailHtml } from './node-page.js';
/**
* Escape a string for safe HTML injection.
@@ -68,9 +68,6 @@ function hasValidReference(reference) {
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* mountCharts?: Function,
* uPlotImpl?: Function,
* loadUPlot?: Function,
* privateMode?: boolean,
* logger?: Console
* }} [options] Behaviour overrides.
@@ -104,9 +101,6 @@ export function createNodeDetailOverlayManager(options = {}) {
const fetchImpl = options.fetchImpl;
const refreshImpl = options.refreshImpl;
const renderShortHtml = options.renderShortHtml;
const mountCharts = typeof options.mountCharts === 'function' ? options.mountCharts : mountTelemetryChartsWithRetry;
const uPlotImpl = options.uPlotImpl;
const loadUPlot = options.loadUPlot;
let requestToken = 0;
let lastTrigger = null;
@@ -204,21 +198,16 @@ export function createNodeDetailOverlayManager(options = {}) {
}
const currentToken = ++requestToken;
try {
const result = await fetchDetail(reference, {
const html = await fetchDetail(reference, {
fetchImpl,
refreshImpl,
renderShortHtml,
privateMode,
returnState: true,
});
if (currentToken !== requestToken) {
return;
}
const resolvedHtml = typeof result === 'string' ? result : result?.html;
content.innerHTML = resolvedHtml ?? '';
if (result && typeof result === 'object' && Array.isArray(result.chartModels)) {
mountCharts(result.chartModels, { root: content, uPlotImpl, loadUPlot });
}
content.innerHTML = html;
if (typeof closeButton.focus === 'function') {
closeButton.focus();
}
+245 -495
View File
@@ -124,15 +124,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
ticks: 4,
color: '#2ca25f',
},
{
id: 'channelSecondary',
position: 'right',
label: 'Utilization (%)',
min: 0,
max: 100,
ticks: 4,
color: '#2ca25f',
},
],
series: [
{
@@ -146,7 +137,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
},
{
id: 'air',
axis: 'channelSecondary',
axis: 'channel',
color: '#99d8c9',
label: 'Air util tx',
legend: 'Air util TX (%)',
@@ -171,13 +162,13 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
},
{
id: 'humidity',
position: 'right',
position: 'left',
label: 'Humidity (%)',
min: 0,
max: 100,
ticks: 4,
color: '#91bfdb',
visible: true,
visible: false,
},
],
series: [
@@ -866,6 +857,67 @@ function createChartDimensions(spec) {
};
}
/**
* Compute the horizontal drawing position for an axis descriptor.
*
* @param {string} position Axis position keyword.
* @param {Object} dims Chart dimensions.
* @returns {number} X coordinate for the axis baseline.
*/
function resolveAxisX(position, dims) {
switch (position) {
case 'leftSecondary':
return dims.margin.left - 32;
case 'right':
return dims.width - dims.margin.right;
case 'rightSecondary':
return dims.width - dims.margin.right + 32;
case 'left':
default:
return dims.margin.left;
}
}
/**
* Compute the X coordinate for a timestamp constrained to the rolling window.
*
* @param {number} timestamp Timestamp in milliseconds.
* @param {number} domainStart Start of the window in milliseconds.
* @param {number} domainEnd End of the window in milliseconds.
* @param {Object} dims Chart dimensions.
* @returns {number} X coordinate inside the SVG viewport.
*/
function scaleTimestamp(timestamp, domainStart, domainEnd, dims) {
const safeStart = Math.min(domainStart, domainEnd);
const safeEnd = Math.max(domainStart, domainEnd);
const span = Math.max(1, safeEnd - safeStart);
const clamped = clamp(timestamp, safeStart, safeEnd);
const ratio = (clamped - safeStart) / span;
return dims.margin.left + ratio * dims.innerWidth;
}
/**
* Convert a value bound to a specific axis into a Y coordinate.
*
* @param {number} value Series value.
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @returns {number} Y coordinate.
*/
function scaleValueToAxis(value, axis, dims) {
if (!axis) return dims.chartBottom;
if (axis.scale === 'log') {
const minLog = Math.log10(axis.min);
const maxLog = Math.log10(axis.max);
const safe = clamp(value, axis.min, axis.max);
const ratio = (Math.log10(safe) - minLog) / (maxLog - minLog);
return dims.chartBottom - ratio * dims.innerHeight;
}
const safe = clamp(value, axis.min, axis.max);
const ratio = (safe - axis.min) / (axis.max - axis.min || 1);
return dims.chartBottom - ratio * dims.innerHeight;
}
/**
* Collect candidate containers that may hold telemetry values for a snapshot.
*
@@ -982,15 +1034,129 @@ function resolveAxisMax(axis, seriesEntries) {
}
/**
* Build a telemetry chart model from a specification and series entries.
* Render a telemetry series as circles plus an optional translucent guide line.
*
* @param {Object} seriesConfig Series metadata.
* @param {Array<{timestamp: number, value: number}>} points Series points.
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @param {number} domainStart Window start timestamp.
* @param {number} domainEnd Window end timestamp.
* @returns {string} SVG markup for the series.
*/
function renderTelemetrySeries(seriesConfig, points, axis, dims, domainStart, domainEnd, { lineReducer } = {}) {
if (!Array.isArray(points) || points.length === 0) {
return '';
}
const convertPoint = point => {
const cx = scaleTimestamp(point.timestamp, domainStart, domainEnd, dims);
const cy = scaleValueToAxis(point.value, axis, dims);
return { cx, cy, value: point.value };
};
const circleEntries = points.map(point => {
const coords = convertPoint(point);
const tooltip = formatSeriesPointValue(seriesConfig, point.value);
const titleMarkup = tooltip ? `<title>${escapeHtml(tooltip)}</title>` : '';
return `<circle class="node-detail__chart-point" cx="${coords.cx.toFixed(2)}" cy="${coords.cy.toFixed(2)}" r="3.2" fill="${seriesConfig.color}" aria-hidden="true">${titleMarkup}</circle>`;
});
const lineSource = typeof lineReducer === 'function' ? lineReducer(points) : points;
const linePoints = Array.isArray(lineSource) && lineSource.length > 0 ? lineSource : points;
const coordinates = linePoints.map(convertPoint);
let line = '';
if (coordinates.length > 1) {
const path = coordinates
.map((coord, idx) => `${idx === 0 ? 'M' : 'L'}${coord.cx.toFixed(2)} ${coord.cy.toFixed(2)}`)
.join(' ');
line = `<path class="node-detail__chart-trend" d="${path}" fill="none" stroke="${hexToRgba(seriesConfig.color, 0.5)}" stroke-width="1.5" aria-hidden="true"></path>`;
}
return `${line}${circleEntries.join('')}`;
}
/**
* Render a vertical axis when visible.
*
* @param {Object} axis Axis descriptor.
* @param {Object} dims Chart dimensions.
* @returns {string} SVG markup for the axis or an empty string.
*/
function renderYAxis(axis, dims) {
if (!axis || axis.visible === false) {
return '';
}
const x = resolveAxisX(axis.position, dims);
const ticks = axis.scale === 'log'
? buildLogTicks(axis.min, axis.max)
: buildLinearTicks(axis.min, axis.max, axis.ticks);
const tickElements = ticks
.map(value => {
const y = scaleValueToAxis(value, axis, dims);
const tickLength = axis.position === 'left' || axis.position === 'leftSecondary' ? -4 : 4;
const textAnchor = axis.position === 'left' || axis.position === 'leftSecondary' ? 'end' : 'start';
const textOffset = axis.position === 'left' || axis.position === 'leftSecondary' ? -6 : 6;
return `
<g class="node-detail__chart-tick" aria-hidden="true">
<line x1="${x}" y1="${y.toFixed(2)}" x2="${(x + tickLength).toFixed(2)}" y2="${y.toFixed(2)}"></line>
<text x="${(x + textOffset).toFixed(2)}" y="${(y + 3).toFixed(2)}" text-anchor="${textAnchor}" dominant-baseline="middle">${escapeHtml(formatAxisTick(value, axis))}</text>
</g>
`;
})
.join('');
const labelPadding = axis.position === 'left' || axis.position === 'leftSecondary' ? -56 : 56;
const labelX = x + labelPadding;
const labelY = (dims.chartTop + dims.chartBottom) / 2;
const labelTransform = `rotate(-90 ${labelX.toFixed(2)} ${labelY.toFixed(2)})`;
return `
<g class="node-detail__chart-axis node-detail__chart-axis--y" aria-hidden="true">
<line x1="${x}" y1="${dims.chartTop}" x2="${x}" y2="${dims.chartBottom}"></line>
${tickElements}
<text class="node-detail__chart-axis-label" x="${labelX.toFixed(2)}" y="${labelY.toFixed(2)}" text-anchor="middle" dominant-baseline="middle" transform="${labelTransform}">${escapeHtml(axis.label)}</text>
</g>
`;
}
/**
* Render the horizontal floating seven-day axis with midnight ticks.
*
* @param {Object} dims Chart dimensions.
* @param {number} domainStart Window start timestamp.
* @param {number} domainEnd Window end timestamp.
* @param {Array<number>} tickTimestamps Midnight tick timestamps.
* @returns {string} SVG markup for the X axis.
*/
function renderXAxis(dims, domainStart, domainEnd, tickTimestamps, { labelFormatter = formatCompactDate } = {}) {
const y = dims.chartBottom;
const ticks = tickTimestamps
.map(ts => {
const x = scaleTimestamp(ts, domainStart, domainEnd, dims);
const labelY = y + 18;
const xStr = x.toFixed(2);
const yStr = labelY.toFixed(2);
const label = labelFormatter(ts);
return `
<g class="node-detail__chart-tick" aria-hidden="true">
<line class="node-detail__chart-grid-line" x1="${xStr}" y1="${dims.chartTop}" x2="${xStr}" y2="${dims.chartBottom}"></line>
<text x="${xStr}" y="${yStr}" text-anchor="end" dominant-baseline="central" transform="rotate(-90 ${xStr} ${yStr})">${escapeHtml(label)}</text>
</g>
`;
})
.join('');
return `
<g class="node-detail__chart-axis node-detail__chart-axis--x" aria-hidden="true">
<line x1="${dims.margin.left}" y1="${y}" x2="${dims.width - dims.margin.right}" y2="${y}"></line>
${ticks}
</g>
`;
}
/**
* Render a single telemetry chart defined by ``spec``.
*
* @param {Object} spec Chart specification.
* @param {Array<{timestamp: number, snapshot: Object}>} entries Telemetry entries.
* @param {number} nowMs Reference timestamp.
* @param {Object} chartOptions Rendering overrides.
* @returns {Object|null} Chart model or ``null`` when empty.
* @returns {string} Rendered chart markup or an empty string.
*/
function buildTelemetryChartModel(spec, entries, nowMs, chartOptions = {}) {
function renderTelemetryChart(spec, entries, nowMs, chartOptions = {}) {
const windowMs = Number.isFinite(chartOptions.windowMs) && chartOptions.windowMs > 0 ? chartOptions.windowMs : TELEMETRY_WINDOW_MS;
const timeRangeLabel = stringOrNull(chartOptions.timeRangeLabel) ?? 'Last 7 days';
const domainEnd = nowMs;
@@ -1004,7 +1170,7 @@ function buildTelemetryChartModel(spec, entries, nowMs, chartOptions = {}) {
})
.filter(entry => entry != null);
if (seriesEntries.length === 0) {
return null;
return '';
}
const adjustedAxes = spec.axes.map(axis => {
const resolvedMax = resolveAxisMax(axis, seriesEntries);
@@ -1022,33 +1188,22 @@ function buildTelemetryChartModel(spec, entries, nowMs, chartOptions = {}) {
})
.filter(entry => entry != null);
if (plottedSeries.length === 0) {
return null;
return '';
}
const axesMarkup = adjustedAxes.map(axis => renderYAxis(axis, dims)).join('');
const tickBuilder = typeof chartOptions.xAxisTickBuilder === 'function' ? chartOptions.xAxisTickBuilder : buildMidnightTicks;
const tickFormatter = typeof chartOptions.xAxisTickFormatter === 'function' ? chartOptions.xAxisTickFormatter : formatCompactDate;
return {
id: spec.id,
title: spec.title,
timeRangeLabel,
domainStart,
domainEnd,
dims,
axes: adjustedAxes,
seriesEntries: plottedSeries,
ticks: tickBuilder(nowMs, windowMs),
tickFormatter,
lineReducer: typeof chartOptions.lineReducer === 'function' ? chartOptions.lineReducer : null,
};
}
const ticks = tickBuilder(nowMs, windowMs);
const xAxisMarkup = renderXAxis(dims, domainStart, domainEnd, ticks, { labelFormatter: tickFormatter });
/**
* Render a telemetry chart container for a chart model.
*
* @param {Object} model Chart model.
* @returns {string} Chart markup.
*/
function renderTelemetryChartMarkup(model) {
const legendItems = model.seriesEntries
const seriesMarkup = plottedSeries
.map(series =>
renderTelemetrySeries(series.config, series.points, series.axis, dims, domainStart, domainEnd, {
lineReducer: chartOptions.lineReducer,
}),
)
.join('');
const legendItems = plottedSeries
.map(series => {
const legendLabel = stringOrNull(series.config.legend) ?? series.config.label;
return `
@@ -1062,428 +1217,22 @@ function renderTelemetryChartMarkup(model) {
const legendMarkup = legendItems
? `<div class="node-detail__chart-legend" aria-hidden="true">${legendItems}</div>`
: '';
const ariaLabel = `${model.title} over last seven days`;
return `
<figure class="node-detail__chart" data-telemetry-chart-id="${escapeHtml(model.id)}">
<figure class="node-detail__chart">
<figcaption class="node-detail__chart-header">
<h4>${escapeHtml(model.title)}</h4>
<span>${escapeHtml(model.timeRangeLabel)}</span>
<h4>${escapeHtml(spec.title)}</h4>
<span>${escapeHtml(timeRangeLabel)}</span>
</figcaption>
<div class="node-detail__chart-plot" data-telemetry-plot role="img" aria-label="${escapeHtml(ariaLabel)}"></div>
<svg viewBox="0 0 ${dims.width} ${dims.height}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="${escapeHtml(`${spec.title} over last seven days`)}">
${axesMarkup}
${xAxisMarkup}
${seriesMarkup}
</svg>
${legendMarkup}
</figure>
`;
}
/**
* Build a sorted timestamp index shared across series entries.
*
* @param {Array<Object>} seriesEntries Plotted series entries.
* @param {Function|null} lineReducer Optional line reducer.
* @returns {{timestamps: Array<number>, indexByTimestamp: Map<number, number>}} Timestamp index.
*/
function buildChartTimestampIndex(seriesEntries, lineReducer) {
const timestampSet = new Set();
for (const entry of seriesEntries) {
if (!entry || !Array.isArray(entry.points)) continue;
entry.points.forEach(point => {
if (point && Number.isFinite(point.timestamp)) {
timestampSet.add(point.timestamp);
}
});
if (typeof lineReducer === 'function') {
const reduced = lineReducer(entry.points);
if (Array.isArray(reduced)) {
reduced.forEach(point => {
if (point && Number.isFinite(point.timestamp)) {
timestampSet.add(point.timestamp);
}
});
}
}
}
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const indexByTimestamp = new Map(timestamps.map((ts, idx) => [ts, idx]));
return { timestamps, indexByTimestamp };
}
/**
* Convert a list of points into an aligned values array.
*
* @param {Array<{timestamp: number, value: number}>} points Series points.
* @param {Map<number, number>} indexByTimestamp Timestamp index.
* @param {number} length Length of the output array.
* @returns {Array<number|null>} Values aligned to timestamps.
*/
function mapSeriesValues(points, indexByTimestamp, length) {
const values = Array.from({ length }, () => null);
if (!Array.isArray(points)) {
return values;
}
for (const point of points) {
if (!point || !Number.isFinite(point.timestamp)) continue;
const idx = indexByTimestamp.get(point.timestamp);
if (idx == null) continue;
values[idx] = Number.isFinite(point.value) ? point.value : null;
}
return values;
}
/**
* Build uPlot series and data arrays for a chart model.
*
* @param {Object} model Chart model.
* @returns {{data: Array<Array<number|null>>, series: Array<Object>}} uPlot data and series config.
*/
function buildTelemetryChartData(model) {
const { timestamps, indexByTimestamp } = buildChartTimestampIndex(model.seriesEntries, model.lineReducer);
const data = [timestamps];
const series = [{ label: 'Time' }];
model.seriesEntries.forEach(entry => {
const baseConfig = {
label: entry.config.label,
scale: entry.axis.id,
};
if (model.lineReducer) {
const reducedPoints = model.lineReducer(entry.points);
const linePoints = Array.isArray(reducedPoints) && reducedPoints.length > 0 ? reducedPoints : entry.points;
const lineValues = mapSeriesValues(linePoints, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: hexToRgba(entry.config.color, 0.5),
width: 1.5,
points: { show: false },
});
data.push(lineValues);
const pointValues = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: entry.config.color,
width: 0,
points: { show: true, size: 6, width: 1 },
});
data.push(pointValues);
} else {
const values = mapSeriesValues(entry.points, indexByTimestamp, timestamps.length);
series.push({
...baseConfig,
stroke: entry.config.color,
width: 1.5,
points: { show: true, size: 6, width: 1 },
});
data.push(values);
}
});
return { data, series };
}
/**
* Build uPlot chart configuration and data for a telemetry chart.
*
* @param {Object} model Chart model.
* @returns {{options: Object, data: Array<Array<number|null>>}} uPlot config and data.
*/
function buildUPlotChartConfig(model, { width, height, axisColor, gridColor } = {}) {
const { data, series } = buildTelemetryChartData(model);
const fallbackWidth = Math.round(model.dims.width * 1.8);
const resolvedWidth = Number.isFinite(width) && width > 0 ? width : fallbackWidth;
const resolvedHeight = Number.isFinite(height) && height > 0 ? height : model.dims.height;
const axisStroke = stringOrNull(axisColor) ?? '#5c6773';
const gridStroke = stringOrNull(gridColor) ?? 'rgba(12, 15, 18, 0.08)';
const axes = [
{
scale: 'x',
side: 2,
stroke: axisStroke,
grid: { show: true, stroke: gridStroke },
splits: () => model.ticks,
values: (u, splits) => splits.map(value => model.tickFormatter(value)),
},
];
const scales = {
x: {
time: true,
range: () => [model.domainStart, model.domainEnd],
},
};
model.axes.forEach(axis => {
const ticks = axis.scale === 'log'
? buildLogTicks(axis.min, axis.max)
: buildLinearTicks(axis.min, axis.max, axis.ticks);
const side = axis.position === 'right' || axis.position === 'rightSecondary' ? 1 : 3;
axes.push({
scale: axis.id,
side,
show: axis.visible !== false,
stroke: axisStroke,
grid: { show: false },
label: axis.label,
splits: () => ticks,
values: (u, splits) => splits.map(value => formatAxisTick(value, axis)),
});
scales[axis.id] = {
distr: axis.scale === 'log' ? 3 : 1,
log: axis.scale === 'log' ? 10 : undefined,
range: () => [axis.min, axis.max],
};
});
return {
options: {
width: resolvedWidth,
height: resolvedHeight,
padding: [
model.dims.margin.top,
model.dims.margin.right,
model.dims.margin.bottom,
model.dims.margin.left,
],
legend: { show: false },
series,
axes,
scales,
},
data,
};
}
/**
* Instantiate uPlot charts for the provided chart models.
*
* @param {Array<Object>} chartModels Chart models to render.
* @param {{root?: ParentNode, uPlotImpl?: Function}} [options] Rendering options.
* @returns {Array<Object>} Instantiated uPlot charts.
*/
export function mountTelemetryCharts(chartModels, { root, uPlotImpl } = {}) {
if (!Array.isArray(chartModels) || chartModels.length === 0) {
return [];
}
const host = root ?? globalThis.document;
if (!host || typeof host.querySelector !== 'function') {
return [];
}
const uPlotCtor = typeof uPlotImpl === 'function' ? uPlotImpl : globalThis.uPlot;
if (typeof uPlotCtor !== 'function') {
console.warn('uPlot is unavailable; telemetry charts will not render.');
return [];
}
const instances = [];
const colorRoot = host?.ownerDocument?.body ?? host?.body ?? globalThis.document?.body ?? null;
const axisColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--muted').trim()
: null;
const gridColor = colorRoot && typeof globalThis.getComputedStyle === 'function'
? globalThis.getComputedStyle(colorRoot).getPropertyValue('--line').trim()
: null;
chartModels.forEach(model => {
const container = host.querySelector(`[data-telemetry-chart-id="${model.id}"]`);
if (!container) return;
const plotRoot = container.querySelector('[data-telemetry-plot]');
if (!plotRoot) return;
plotRoot.innerHTML = '';
const plotWidth = plotRoot.clientWidth || plotRoot.getBoundingClientRect?.().width;
const plotHeight = plotRoot.clientHeight || plotRoot.getBoundingClientRect?.().height;
const { options, data } = buildUPlotChartConfig(model, {
width: plotWidth ? Math.round(plotWidth) : undefined,
height: plotHeight ? Math.round(plotHeight) : undefined,
axisColor: axisColor || undefined,
gridColor: gridColor || undefined,
});
const instance = new uPlotCtor(options, data, plotRoot);
instance.__potatoMeshRoot = plotRoot;
instances.push(instance);
});
registerTelemetryChartResize(instances);
return instances;
}
const telemetryResizeRegistry = new Set();
const telemetryResizeObservers = new WeakMap();
let telemetryResizeListenerAttached = false;
let telemetryResizeDebounceId = null;
const TELEMETRY_RESIZE_DEBOUNCE_MS = 120;
function resizeUPlotInstance(instance) {
if (!instance || typeof instance.setSize !== 'function') {
return;
}
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
if (!root) return;
const rect = typeof root.getBoundingClientRect === 'function' ? root.getBoundingClientRect() : null;
const width = Number.isFinite(root.clientWidth) ? root.clientWidth : rect?.width;
const height = Number.isFinite(root.clientHeight) ? root.clientHeight : rect?.height;
if (!width || !height) return;
instance.setSize({ width: Math.round(width), height: Math.round(height) });
}
function registerTelemetryChartResize(instances) {
if (!Array.isArray(instances) || instances.length === 0) {
return;
}
const scheduleResize = () => {
if (telemetryResizeDebounceId != null) {
clearTimeout(telemetryResizeDebounceId);
}
telemetryResizeDebounceId = setTimeout(() => {
telemetryResizeDebounceId = null;
telemetryResizeRegistry.forEach(instance => resizeUPlotInstance(instance));
}, TELEMETRY_RESIZE_DEBOUNCE_MS);
};
instances.forEach(instance => {
telemetryResizeRegistry.add(instance);
resizeUPlotInstance(instance);
if (typeof globalThis.ResizeObserver === 'function') {
if (telemetryResizeObservers.has(instance)) return;
const observer = new globalThis.ResizeObserver(scheduleResize);
telemetryResizeObservers.set(instance, observer);
const root = instance.__potatoMeshRoot ?? instance.root ?? null;
if (root && typeof observer.observe === 'function') {
observer.observe(root);
}
}
});
if (!telemetryResizeListenerAttached && typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('resize', () => {
scheduleResize();
});
telemetryResizeListenerAttached = true;
}
}
function defaultLoadUPlot({ documentRef, onLoad }) {
if (!documentRef || typeof documentRef.querySelector !== 'function') {
return false;
}
const existing = documentRef.querySelector('script[data-uplot-loader="true"]');
if (existing) {
if (existing.dataset.loaded === 'true' && typeof onLoad === 'function') {
onLoad();
} else if (typeof existing.addEventListener === 'function' && typeof onLoad === 'function') {
existing.addEventListener('load', onLoad, { once: true });
}
return true;
}
if (typeof documentRef.createElement !== 'function') {
return false;
}
const script = documentRef.createElement('script');
script.src = '/assets/vendor/uplot/uPlot.iife.min.js';
script.defer = true;
script.dataset.uplotLoader = 'true';
if (typeof script.addEventListener === 'function') {
script.addEventListener('load', () => {
script.dataset.loaded = 'true';
if (typeof onLoad === 'function') {
onLoad();
}
});
}
const head = documentRef.head ?? documentRef.body;
if (head && typeof head.appendChild === 'function') {
head.appendChild(script);
return true;
}
return false;
}
/**
* Mount telemetry charts, retrying briefly if uPlot has not loaded yet.
*
* @param {Array<Object>} chartModels Chart models to render.
* @param {{root?: ParentNode, uPlotImpl?: Function, loadUPlot?: Function}} [options] Rendering options.
* @returns {Array<Object>} Instantiated uPlot charts.
*/
export function mountTelemetryChartsWithRetry(chartModels, { root, uPlotImpl, loadUPlot } = {}) {
const instances = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (instances.length > 0 || typeof uPlotImpl === 'function') {
return instances;
}
const host = root ?? globalThis.document;
if (!host || typeof host.querySelector !== 'function') {
return instances;
}
let mounted = false;
let attempts = 0;
const maxAttempts = 10;
const retryDelayMs = 50;
const retry = () => {
if (mounted) return;
attempts += 1;
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (next.length > 0) {
mounted = true;
return;
}
if (attempts >= maxAttempts) {
return;
}
setTimeout(retry, retryDelayMs);
};
const loadFn = typeof loadUPlot === 'function' ? loadUPlot : defaultLoadUPlot;
loadFn({
documentRef: host.ownerDocument ?? globalThis.document,
onLoad: () => {
const next = mountTelemetryCharts(chartModels, { root, uPlotImpl });
if (next.length > 0) {
mounted = true;
}
},
});
setTimeout(retry, 0);
return instances;
}
/**
* Create chart markup and models for telemetry charts.
*
* @param {Object} node Normalised node payload.
* @param {{ nowMs?: number, chartOptions?: Object }} [options] Rendering options.
* @returns {{chartsHtml: string, chartModels: Array<Object>}} Chart markup and models.
*/
export function createTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
const telemetrySource = node?.rawSources?.telemetry;
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
? node.rawSources.telemetrySnapshots
: null;
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
? telemetrySource.snapshots
: null;
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const entries = rawSnapshots
.map(snapshot => {
const timestamp = resolveSnapshotTimestamp(snapshot);
if (timestamp == null) return null;
return { timestamp, snapshot };
})
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
.sort((a, b) => a.timestamp - b.timestamp);
if (entries.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const chartModels = TELEMETRY_CHART_SPECS
.map(spec => buildTelemetryChartModel(spec, entries, nowMs, chartOptions))
.filter(model => model != null);
if (chartModels.length === 0) {
return { chartsHtml: '', chartModels: [] };
}
const chartsHtml = `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${chartModels.map(model => renderTelemetryChartMarkup(model)).join('')}
</div>
</section>
`;
return { chartsHtml, chartModels };
}
/**
* Render the telemetry charts for the supplied node when telemetry snapshots
* exist.
@@ -1493,7 +1242,41 @@ export function createTelemetryCharts(node, { nowMs = Date.now(), chartOptions =
* @returns {string} Chart grid markup or an empty string.
*/
export function renderTelemetryCharts(node, { nowMs = Date.now(), chartOptions = {} } = {}) {
return createTelemetryCharts(node, { nowMs, chartOptions }).chartsHtml;
const telemetrySource = node?.rawSources?.telemetry;
const snapshotHistory = Array.isArray(node?.rawSources?.telemetrySnapshots) && node.rawSources.telemetrySnapshots.length > 0
? node.rawSources.telemetrySnapshots
: null;
const aggregatedSnapshots = Array.isArray(telemetrySource?.snapshots)
? telemetrySource.snapshots
: null;
const rawSnapshots = snapshotHistory ?? aggregatedSnapshots;
if (!Array.isArray(rawSnapshots) || rawSnapshots.length === 0) {
return '';
}
const entries = rawSnapshots
.map(snapshot => {
const timestamp = resolveSnapshotTimestamp(snapshot);
if (timestamp == null) return null;
return { timestamp, snapshot };
})
.filter(entry => entry != null && entry.timestamp >= nowMs - TELEMETRY_WINDOW_MS && entry.timestamp <= nowMs)
.sort((a, b) => a.timestamp - b.timestamp);
if (entries.length === 0) {
return '';
}
const charts = TELEMETRY_CHART_SPECS
.map(spec => renderTelemetryChart(spec, entries, nowMs, chartOptions))
.filter(chart => stringOrNull(chart));
if (charts.length === 0) {
return '';
}
return `
<section class="node-detail__charts">
<div class="node-detail__charts-grid">
${charts.join('')}
</div>
</section>
`;
}
/**
@@ -2515,7 +2298,6 @@ function renderTraceroutes(traces, renderShortHtml, { roleIndex = null, node = n
* messages?: Array<Object>,
* traces?: Array<Object>,
* renderShortHtml: Function,
* chartsHtml?: string,
* }} options Rendering options.
* @returns {string} HTML fragment representing the detail view.
*/
@@ -2525,7 +2307,6 @@ function renderNodeDetailHtml(node, {
traces = [],
renderShortHtml,
roleIndex = null,
chartsHtml = null,
chartNowMs = Date.now(),
} = {}) {
const roleAwareBadge = renderRoleAwareBadge(renderShortHtml, {
@@ -2539,7 +2320,7 @@ function renderNodeDetailHtml(node, {
const longName = stringOrNull(node.longName ?? node.long_name);
const identifier = stringOrNull(node.nodeId ?? node.node_id);
const tableHtml = renderSingleNodeTable(node, renderShortHtml);
const telemetryChartsHtml = stringOrNull(chartsHtml) ?? renderTelemetryCharts(node, { nowMs: chartNowMs });
const chartsHtml = renderTelemetryCharts(node, { nowMs: chartNowMs });
const neighborsHtml = renderNeighborGroups(node, neighbors, renderShortHtml, { roleIndex });
const tracesHtml = renderTraceroutes(traces, renderShortHtml, { roleIndex, node });
const messagesHtml = renderMessages(messages, renderShortHtml, node);
@@ -2565,7 +2346,7 @@ function renderNodeDetailHtml(node, {
<header class="node-detail__header">
<h2 class="node-detail__title">${badgeHtml}${nameHtml}${identifierHtml}</h2>
</header>
${telemetryChartsHtml ?? ''}
${chartsHtml ?? ''}
${tableSection}
${contentHtml}
`;
@@ -2679,17 +2460,15 @@ async function fetchTracesForNode(identifier, { fetchImpl } = {}) {
}
/**
* Fetch node detail data and render the HTML fragment.
* Initialise the node detail page by hydrating the DOM with fetched data.
*
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* chartNowMs?: number,
* chartOptions?: Object,
* }} options Optional overrides for testing.
* @returns {Promise<string|{html: string, chartModels: Array<Object>}>} Rendered markup or chart models when requested.
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
*/
export async function fetchNodeDetailHtml(referenceData, options = {}) {
if (!referenceData || typeof referenceData !== 'object') {
@@ -2719,38 +2498,15 @@ export async function fetchNodeDetailHtml(referenceData, options = {}) {
fetchTracesForNode(messageIdentifier, { fetchImpl: options.fetchImpl }),
]);
const roleIndex = await buildTraceRoleIndex(traces, neighborRoleIndex, { fetchImpl: options.fetchImpl });
const chartNowMs = Number.isFinite(options.chartNowMs) ? options.chartNowMs : Date.now();
const chartState = createTelemetryCharts(node, {
nowMs: chartNowMs,
chartOptions: options.chartOptions ?? {},
});
const html = renderNodeDetailHtml(node, {
return renderNodeDetailHtml(node, {
neighbors: node.neighbors,
messages,
traces,
renderShortHtml,
roleIndex,
chartsHtml: chartState.chartsHtml,
chartNowMs,
});
if (options.returnState === true) {
return { html, chartModels: chartState.chartModels };
}
return html;
}
/**
* Initialise the standalone node detail page and mount telemetry charts.
*
* @param {{
* document?: Document,
* fetchImpl?: Function,
* refreshImpl?: Function,
* renderShortHtml?: Function,
* uPlotImpl?: Function,
* }} options Optional overrides for testing.
* @returns {Promise<boolean>} ``true`` when the node was rendered successfully.
*/
export async function initializeNodeDetailPage(options = {}) {
const documentRef = options.document ?? globalThis.document;
if (!documentRef || typeof documentRef.querySelector !== 'function') {
@@ -2787,15 +2543,13 @@ export async function initializeNodeDetailPage(options = {}) {
const privateMode = (root.dataset?.privateMode ?? '').toLowerCase() === 'true';
try {
const result = await fetchNodeDetailHtml(referenceData, {
const html = await fetchNodeDetailHtml(referenceData, {
fetchImpl: options.fetchImpl,
refreshImpl,
renderShortHtml: options.renderShortHtml,
privateMode,
returnState: true,
});
root.innerHTML = result.html;
mountTelemetryChartsWithRetry(result.chartModels, { root, uPlotImpl: options.uPlotImpl });
root.innerHTML = html;
return true;
} catch (error) {
console.error('Failed to render node detail page', error);
@@ -2832,11 +2586,7 @@ export const __testUtils = {
categoriseNeighbors,
renderNeighborGroups,
renderSingleNodeTable,
createTelemetryCharts,
renderTelemetryCharts,
mountTelemetryCharts,
mountTelemetryChartsWithRetry,
buildUPlotChartConfig,
renderMessages,
renderTraceroutes,
renderTracePath,
+218 -49
View File
@@ -215,25 +215,214 @@ h1 {
.site-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
padding: 4px 0;
margin-bottom: 8px;
}
.site-header__left,
.site-header__right {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.site-header__left {
flex: 1 1 auto;
min-width: 0;
}
.site-header__right {
flex: 0 0 auto;
margin-left: auto;
}
.site-title {
display: inline-flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.site-title-text {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.site-title img {
width: 52px;
height: 52px;
width: 36px;
height: 36px;
display: block;
border-radius: 12px;
}
.site-nav {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.site-nav__link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
color: var(--fg);
text-decoration: none;
border: 1px solid transparent;
font-size: 14px;
}
.site-nav__link:hover {
background: var(--card);
}
.site-nav__link.is-active {
border-color: var(--accent);
color: var(--accent);
background: transparent;
font-weight: 600;
}
.site-nav__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.menu-toggle {
display: none;
}
.menu-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-menu {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
.mobile-menu[hidden] {
display: none;
}
.mobile-menu__backdrop {
flex: 1 1 auto;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 200ms ease;
}
.mobile-menu__panel {
width: min(320px, 86vw);
background: var(--bg2);
color: var(--fg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
transform: translateX(100%);
transition: transform 220ms ease;
box-shadow: -12px 0 32px rgba(0, 0, 0, 0.3);
}
.mobile-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mobile-menu__title {
margin: 0;
font-size: 16px;
}
.mobile-menu__close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-nav {
display: flex;
flex-direction: column;
gap: 8px;
}
.mobile-nav__link {
display: inline-flex;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
color: var(--fg);
text-decoration: none;
border: 1px solid transparent;
}
.mobile-nav__link.is-active {
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.mobile-nav__link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.mobile-menu.is-open {
pointer-events: auto;
}
.mobile-menu.is-open .mobile-menu__backdrop {
opacity: 1;
}
.mobile-menu.is-open .mobile-menu__panel {
transform: translateX(0);
}
.menu-open {
overflow: hidden;
}
.section-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--fg);
text-decoration: none;
font-size: 14px;
}
.section-link:hover {
border-color: var(--accent);
color: var(--accent);
}
.section-link:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.meta {
color: #555;
margin-bottom: 12px;
@@ -282,11 +471,29 @@ h1 {
@media (max-width: 900px) {
.site-header {
flex-direction: column;
align-items: flex-start;
margin-bottom: 4px;
}
.site-header__left {
flex-wrap: nowrap;
}
.site-header__left--federation {
flex-wrap: wrap;
}
.site-nav {
display: none;
}
.menu-toggle {
display: inline-flex;
}
.instance-selector {
flex: 0 1 auto;
}
.instance-selector,
.instance-select {
width: 100%;
}
@@ -296,6 +503,7 @@ h1 {
}
}
.pill {
display: inline-block;
padding: 2px 8px;
@@ -994,7 +1202,7 @@ body.dark .node-detail-overlay__close:hover {
.node-detail__charts-grid {
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 1152px), 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
}
.node-detail__chart {
@@ -1026,45 +1234,10 @@ body.dark .node-detail-overlay__close:hover {
font-size: 1rem;
}
.node-detail__chart-plot {
.node-detail__chart svg {
width: 100%;
height: clamp(240px, 50vw, 360px);
height: auto;
max-height: 420px;
overflow: hidden;
}
.node-detail__chart-plot .uplot {
width: 100%;
height: 100%;
margin: 0;
line-height: 0;
position: relative;
}
.node-detail__chart-plot .uplot .u-wrap,
.node-detail__chart-plot .uplot .u-under,
.node-detail__chart-plot .uplot .u-over {
top: 0;
left: 0;
}
.node-detail__chart-plot .u-axis,
.node-detail__chart-plot .u-axis .u-label,
.node-detail__chart-plot .u-axis .u-value,
.node-detail__chart-plot .u-axis text,
.node-detail__chart-plot .u-axis-label {
color: var(--muted) !important;
fill: var(--muted) !important;
font-size: 0.95rem;
}
.node-detail__chart-plot .u-grid {
stroke: rgba(12, 15, 18, 0.08);
stroke-width: 1;
}
body.dark .node-detail__chart-plot .u-grid {
stroke: rgba(255, 255, 255, 0.15);
}
.node-detail__chart-axis line {
@@ -1729,10 +1902,6 @@ input[type="radio"] {
gap: 12px;
}
.controls--full-screen {
grid-template-columns: minmax(0, 1fr) auto;
}
.controls .filter-input {
width: 100%;
}
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
.uplot, .uplot *, .uplot *::before, .uplot *::after {box-sizing: border-box;}.uplot {font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";line-height: 1.5;width: min-content;}.u-title {text-align: center;font-size: 18px;font-weight: bold;}.u-wrap {position: relative;user-select: none;}.u-over, .u-under {position: absolute;}.u-under {overflow: hidden;}.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}.u-axis {position: absolute;}.u-legend {font-size: 14px;margin: auto;text-align: center;}.u-inline {display: block;}.u-inline * {display: inline-block;}.u-inline tr {margin-right: 16px;}.u-legend th {font-weight: 600;}.u-legend th > * {vertical-align: middle;display: inline-block;}.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}.u-inline.u-live th::after {content: ":";vertical-align: middle;}.u-inline:not(.u-live) .u-value {display: none;}.u-series > * {padding: 4px;}.u-series th {cursor: pointer;}.u-legend .u-off > * {opacity: 0.3;}.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;}.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;/*this has to be !important since we set inline "background" shorthand */background-clip: padding-box !important;}.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
-55
View File
@@ -1,55 +0,0 @@
/*
* Copyright © 2025-26 l5yth & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mkdir, copyFile, access } from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Resolve an absolute path relative to this script location.
*
* @param {string[]} segments Path segments to append.
* @returns {string} Absolute path resolved from this script.
*/
function resolvePath(...segments) {
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(scriptDir, ...segments);
}
/**
* Ensure the uPlot assets are available within the public asset tree.
*
* @returns {Promise<void>} Resolves once files have been copied.
*/
async function copyUPlotAssets() {
const sourceDir = resolvePath('..', 'node_modules', 'uplot', 'dist');
const targetDir = resolvePath('..', 'public', 'assets', 'vendor', 'uplot');
const assets = ['uPlot.iife.min.js', 'uPlot.min.css'];
await access(sourceDir, fsConstants.R_OK);
await mkdir(targetDir, { recursive: true });
await Promise.all(
assets.map(async asset => {
const source = path.join(sourceDir, asset);
const target = path.join(targetDir, asset);
await copyFile(source, target);
}),
);
}
await copyUPlotAssets();
+15 -14
View File
@@ -4136,7 +4136,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "excludes nodes whose last activity is older than a week" do
it "excludes nodes whose last activity is older than a week from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4162,7 +4162,9 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(ids).not_to include("!stale-node")
get "/api/nodes/!stale-node"
expect(last_response.status).to eq(404)
expect(last_response).to be_ok
payload = JSON.parse(last_response.body)
expect(payload["node_id"]).to eq("!stale-node")
get "/api/nodes/!fresh-node"
expect(last_response).to be_ok
@@ -4532,7 +4534,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(entry["payload_b64"]).to eq("AQI=")
end
it "excludes position entries older than seven days" do
it "excludes position entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4561,7 +4563,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters positions using the since parameter for both global and node queries" do
@@ -4646,11 +4648,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
describe "GET /api/neighbors" do
it "excludes neighbor records older than seven days" do
it "excludes neighbor records older than twenty-eight days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
stale_rx = now - (PotatoMesh::Config.week_seconds + 45)
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds + 45)
fresh_rx = now - 10
with_db do |db|
@@ -4688,9 +4690,8 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.length).to eq(1)
expect(filtered.first["neighbor_id"]).to eq("!neighbor-new")
expect(filtered.first["rx_time"]).to eq(fresh_rx)
expect(filtered.length).to eq(2)
expect(filtered.map { |row| row["neighbor_id"] }).to eq(["!neighbor-new", "!neighbor-old"])
end
it "honours the since parameter for neighbor queries" do
@@ -4834,7 +4835,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect_same_value(second_entry["soil_temperature"], telemetry_metric(second_latest, "soil_temperature"))
end
it "excludes telemetry entries older than seven days" do
it "excludes telemetry entries older than seven days from collection queries" do
clear_database
allow(Time).to receive(:now).and_return(reference_time)
now = reference_time.to_i
@@ -4863,7 +4864,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(last_response).to be_ok
filtered = JSON.parse(last_response.body)
expect(filtered.map { |row| row["id"] }).to eq([2])
expect(filtered.map { |row| row["id"] }).to eq([2, 1])
end
it "filters telemetry rows using the since parameter for both global and node-scoped queries" do
@@ -5236,11 +5237,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(JSON.parse(last_response.body)).to eq([])
end
it "excludes traces older than one week" do
it "excludes traces older than twenty-eight days" do
clear_database
now = Time.now.to_i
recent_rx = now - (PotatoMesh::Config.week_seconds / 2)
stale_rx = now - (PotatoMesh::Config.week_seconds + 60)
recent_rx = now - (PotatoMesh::Config.trace_neighbor_window_seconds / 2)
stale_rx = now - (PotatoMesh::Config.trace_neighbor_window_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" => {} },
+1 -3
View File
@@ -13,14 +13,12 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
<section class="charts-page">
<header class="charts-page__intro">
<h2>Network telemetry trends</h2>
<p>Aggregated telemetry snapshots from every node in the past week.</p>
</header>
<div id="chartsPage" class="charts-page__content" data-telemetry-root="true">
<div id="chartsPage" class="charts-page__content">
<p class="charts-page__status">Loading aggregated telemetry charts…</p>
</div>
</section>
+64 -16
View File
@@ -75,16 +75,19 @@
main_classes = ["page-main"]
main_classes << "page-main--dashboard" if view_mode == :dashboard
main_classes << "page-main--full-screen" if full_screen_view
show_header = !full_screen_view
show_header = true
show_meta_info = 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_info_button = true
show_footer = !full_screen_view
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
nodes_nav_href = "/nodes"
nodes_nav_active = %i[nodes node_detail].include?(view_mode)
federation_nav_enabled = !private_mode && federation_enabled
controls_classes = ["controls"]
controls_classes << "controls--full-screen" if full_screen_view
refresh_row_classes = ["refresh-row"]
@@ -101,24 +104,69 @@
<div class="<%= shell_classes.join(" ") %>">
<% if show_header %>
<header class="site-header">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if !private_mode && federation_enabled %>
<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 class="site-header__left<%= federation_nav_enabled ? " site-header__left--federation" : "" %>">
<h1 class="site-title">
<img src="/potatomesh-logo.svg" alt="" aria-hidden="true" />
<span class="site-title-text"><%= site_name %></span>
</h1>
<% if federation_nav_enabled %>
<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>
</div>
<% end %>
<% end %>
</div>
<div class="site-header__right">
<nav class="site-nav" aria-label="Primary">
<a href="/" class="site-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
<a href="/map" class="site-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="site-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="site-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="site-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="site-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
</nav>
<button
id="mobileMenuToggle"
class="icon-button menu-toggle"
type="button"
aria-label="Open navigation menu"
aria-expanded="false"
aria-controls="mobileMenu"
>
<span aria-hidden="true">☰</span>
</button>
</div>
</header>
<div id="mobileMenu" class="mobile-menu" hidden>
<div class="mobile-menu__backdrop" data-mobile-menu-close></div>
<div class="mobile-menu__panel" role="dialog" aria-modal="true" aria-labelledby="mobileMenuTitle" tabindex="-1">
<div class="mobile-menu__header">
<h2 id="mobileMenuTitle" class="mobile-menu__title">Menu</h2>
<button class="icon-button mobile-menu__close" type="button" data-mobile-menu-close aria-label="Close navigation menu">
<span aria-hidden="true">×</span>
</button>
</div>
<nav class="mobile-nav" aria-label="Mobile">
<a href="/" class="mobile-nav__link<%= view_mode == :dashboard ? " is-active" : "" %>"<%= view_mode == :dashboard ? ' aria-current="page"' : "" %>>Dashboard</a>
<a href="/map" class="mobile-nav__link<%= view_mode == :map ? " is-active" : "" %>"<%= view_mode == :map ? ' aria-current="page"' : "" %>>Map</a>
<a href="/chat" class="mobile-nav__link<%= view_mode == :chat ? " is-active" : "" %>"<%= view_mode == :chat ? ' aria-current="page"' : "" %>>Chat</a>
<a href="<%= nodes_nav_href %>" class="mobile-nav__link<%= nodes_nav_active ? " is-active" : "" %>"<%= nodes_nav_active ? ' aria-current="page"' : "" %>>Nodes</a>
<a href="/charts" class="mobile-nav__link<%= view_mode == :charts ? " is-active" : "" %>"<%= view_mode == :charts ? ' aria-current="page"' : "" %>>Charts</a>
<% if federation_nav_enabled %>
<a href="/federation" class="mobile-nav__link<%= view_mode == :federation ? " is-active" : "" %>"<%= view_mode == :federation ? ' aria-current="page"' : "" %>>Federation</a>
<% end %>
</nav>
</div>
</div>
<% end %>
<div class="row meta">
<div id="metaRow" class="row meta">
<% if show_meta_info %>
<div class="meta-info">
<div class="<%= refresh_row_classes.join(" ") %>">
-3
View File
@@ -17,14 +17,11 @@
short_display = node_page_short_name || "Loading"
long_display = node_page_long_name
identifier_display = node_page_identifier || "" %>
<link rel="stylesheet" href="/assets/vendor/uplot/uPlot.min.css" />
<script src="/assets/vendor/uplot/uPlot.iife.min.js" defer></script>
<section
id="nodeDetail"
class="node-detail"
data-node-reference="<%= Rack::Utils.escape_html(reference_json) %>"
data-private-mode="<%= private_mode ? "true" : "false" %>"
data-telemetry-root="true"
>
<header class="node-detail__header">
<h2 class="node-detail__title">
+1 -1
View File
@@ -13,7 +13,7 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="nodes-table-wrapper">
<div id="nodes-table" class="nodes-table-wrapper">
<table id="nodes">
<thead>
<tr>