mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-07 05:44:50 +02:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c157fd481b | |||
| a6fc7145bc | |||
| ca05cbb2c5 | |||
| 5c79572c4d |
+262
-107
@@ -28,7 +28,16 @@ use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
|
||||
|
||||
#[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 +47,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 +66,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 +101,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 +114,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 +136,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);
|
||||
@@ -176,12 +188,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,6 +206,7 @@ async fn handle_message(
|
||||
|
||||
// Ensure puppet exists & has display name
|
||||
matrix.ensure_user_registered(&localpart).await?;
|
||||
matrix.ensure_user_joined_room(&user_id).await?;
|
||||
matrix.set_display_name(&user_id, &node.long_name).await?;
|
||||
|
||||
// Format the bridged message
|
||||
@@ -207,30 +215,60 @@ async fn handle_message(
|
||||
.clone()
|
||||
.unwrap_or_else(|| node.long_name.clone());
|
||||
|
||||
let body = format!(
|
||||
"[{short}] {text}\n({from_id} → {to_id}, {rssi}, {snr}, {chan}/{preset})",
|
||||
let preset_short = modem_preset_short(&msg.modem_preset);
|
||||
let prefix = format!(
|
||||
"[{freq}][{preset_short}][{channel}][{short}]",
|
||||
freq = msg.lora_freq,
|
||||
preset_short = preset_short,
|
||||
channel = msg.channel_name,
|
||||
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 (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)
|
||||
}
|
||||
|
||||
/// 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("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(ch),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -259,6 +297,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 <&>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_html_escapes_quotes() {
|
||||
assert_eq!(escape_html("a\"b'c"), "a"b'c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_state_initially_forwards_all() {
|
||||
let state = BridgeState::default();
|
||||
@@ -268,39 +324,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 +400,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 +421,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 +476,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 +490,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 +501,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 +532,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 +608,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,6 +624,15 @@ 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",
|
||||
@@ -492,8 +644,6 @@ mod tests {
|
||||
|
||||
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 +658,10 @@ 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",
|
||||
"format": "org.matrix.custom.html",
|
||||
})))
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
@@ -520,6 +674,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
mock_get_node.assert();
|
||||
mock_register.assert();
|
||||
mock_join.assert();
|
||||
mock_display_name.assert();
|
||||
mock_send.assert();
|
||||
|
||||
|
||||
+89
-25
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-10
@@ -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,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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -994,7 +994,7 @@ body.dark .node-detail-overlay__close:hover {
|
||||
.node-detail__charts-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 1152px), 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 640px), 1fr));
|
||||
}
|
||||
|
||||
.node-detail__chart {
|
||||
@@ -1026,45 +1026,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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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;}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user