Working on better plotting
This commit is contained in:
Generated
+1467
-692
File diff suppressed because it is too large
Load Diff
+16
-17
@@ -1,36 +1,35 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"rmon-common",
|
|
||||||
"rmon-agent",
|
"rmon-agent",
|
||||||
|
"rmon-common",
|
||||||
"rmon-ui",
|
"rmon-ui",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
authors = ["RMon Team"]
|
|
||||||
license = "MIT"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1.0"
|
|
||||||
bincode = "1.3"
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
tokio = { version = "1.36", features = ["full"] }
|
tokio = { version = "1.36", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
toml = "0.8"
|
|
||||||
futures = "0.3"
|
|
||||||
bytes = "1.5"
|
|
||||||
itertools = "0.12"
|
|
||||||
thiserror = "1.0"
|
|
||||||
byteorder = "1.5"
|
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
tokio-util = { version = "0.7", features = ["codec"] }
|
tokio-util = { version = "0.7", features = ["codec"] }
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
notify = "6.1"
|
notify = "6.1"
|
||||||
clap = { version = "4.4", features = ["derive"] }
|
clap = { version = "4.4", features = ["derive"] }
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
uuid = { version = "1.7", features = ["v4"] }
|
||||||
|
egui_tiles = "0.10"
|
||||||
|
eframe = "0.29"
|
||||||
|
egui = "0.29"
|
||||||
|
egui_plot = "0.29"
|
||||||
|
chrono = "0.4"
|
||||||
|
itertools = "0.12"
|
||||||
|
byteorder = "1.5"
|
||||||
|
bytes = "1.5"
|
||||||
|
bincode = "1.3"
|
||||||
rmon-common = { path = "rmon-common" }
|
rmon-common = { path = "rmon-common" }
|
||||||
|
|||||||
+13
-28
@@ -1,39 +1,24 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rmon-agent"
|
name = "rmon-agent"
|
||||||
version.workspace = true
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
rmon-common = { path = "../rmon-common" }
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-util.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
serde.workspace = true
|
async-trait.workspace = true
|
||||||
chrono.workspace = true
|
futures.workspace = true
|
||||||
toml.workspace = true
|
tokio-util.workspace = true
|
||||||
bincode.workspace = true
|
|
||||||
regex.workspace = true
|
regex.workspace = true
|
||||||
notify.workspace = true
|
notify.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
futures.workspace = true
|
|
||||||
bytes.workspace = true
|
|
||||||
itertools.workspace = true
|
|
||||||
byteorder.workspace = true
|
|
||||||
async-trait.workspace = true
|
|
||||||
rmon-common.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tokio = { workspace = true, features = ["full"] }
|
|
||||||
futures.workspace = true
|
|
||||||
toml.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
byteorder.workspace = true
|
||||||
[profile.release]
|
chrono.workspace = true
|
||||||
strip = true
|
bytes.workspace = true
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|||||||
@@ -117,12 +117,37 @@ pub async fn handle_session(
|
|||||||
tracing::info!("[{}] Updating config...", addr);
|
tracing::info!("[{}] Updating config...", addr);
|
||||||
if let Err(e) = std::fs::write(&state.config_path, &config_toml) {
|
if let Err(e) = std::fs::write(&state.config_path, &config_toml) {
|
||||||
tracing::error!("[{}] Config write failed: {}", addr, e);
|
tracing::error!("[{}] Config write failed: {}", addr, e);
|
||||||
framed.send(AgentMessage::Error { context: "UpdateConfig".to_string(), message: e.to_string() }).await?;
|
let _ = framed.send(AgentMessage::Error { context: "UpdateConfig".to_string(), message: e.to_string() }).await;
|
||||||
} else {
|
} else {
|
||||||
let _ = state.reload_tx.send(());
|
let _ = state.reload_tx.send(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ClientMessage::QueryHistory { id, from_ns, to_ns, max_points } => {
|
||||||
|
tracing::info!("[{}] QueryHistory for {}", addr, id);
|
||||||
|
let source_name = id.split("://").nth(1).and_then(|s| s.split('/').next()).unwrap_or("unknown");
|
||||||
|
match crate::storage::ContinuousStorage::read_range(
|
||||||
|
state.config_path.parent().unwrap().join("data"), // Assuming data is in same dir as config
|
||||||
|
source_name,
|
||||||
|
&id,
|
||||||
|
from_ns,
|
||||||
|
to_ns,
|
||||||
|
max_points as usize
|
||||||
|
) {
|
||||||
|
Ok(samples) => {
|
||||||
|
framed.send(AgentMessage::HistoryChunk {
|
||||||
|
id,
|
||||||
|
samples,
|
||||||
|
done: true
|
||||||
|
}).await?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("[{}] QueryHistory failed: {}", addr, e);
|
||||||
|
let _ = framed.send(AgentMessage::Error { context: "QueryHistory".to_string(), message: e.to_string() }).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ClientMessage::Subscribe { ids, .. } => {
|
ClientMessage::Subscribe { ids, .. } => {
|
||||||
|
|
||||||
tracing::info!("[{}] Subscribing to {:?}", addr, ids);
|
tracing::info!("[{}] Subscribing to {:?}", addr, ids);
|
||||||
subscriptions.extend(ids);
|
subscriptions.extend(ids);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
use std::fs::{File, OpenOptions};
|
|
||||||
use std::io::{Write, BufWriter};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use rmon_common::signal::Sample;
|
use rmon_common::signal::Sample;
|
||||||
use byteorder::{LittleEndian, WriteBytesExt};
|
use std::fs::File;
|
||||||
|
use std::io::{BufWriter, BufReader, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use byteorder::{LittleEndian, WriteBytesExt, ReadBytesExt};
|
||||||
|
|
||||||
pub struct ContinuousStorage {
|
pub struct ContinuousStorage {
|
||||||
writer: BufWriter<File>,
|
writer: BufWriter<File>,
|
||||||
_path: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ContinuousStorage {
|
impl ContinuousStorage {
|
||||||
pub fn create(data_dir: PathBuf, source_name: &str, signal_id: &str, session_start_ns: i64) -> Result<Self> {
|
pub fn create(data_dir: PathBuf, source_name: &str, signal_id: &str, session_start: i64) -> Result<Self> {
|
||||||
let mut path = data_dir;
|
let mut path = data_dir;
|
||||||
path.push(source_name);
|
path.push(source_name);
|
||||||
path.push(signal_id.replace(":", "_").replace("/", "_")); // Escape signal ID for filesystem
|
path.push(signal_id.replace(":", "_").replace("/", "_"));
|
||||||
std::fs::create_dir_all(&path)?;
|
std::fs::create_dir_all(&path)?;
|
||||||
|
|
||||||
path.push(format!("{}.bin", session_start_ns));
|
let filename = format!("{}.bin", session_start);
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
let file = OpenOptions::new()
|
|
||||||
.create(true)
|
.create(true)
|
||||||
.append(true)
|
.append(true)
|
||||||
.open(&path)?;
|
.open(path.join(filename))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
writer: BufWriter::new(file),
|
writer: BufWriter::new(file),
|
||||||
_path: path,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,43 +37,67 @@ impl ContinuousStorage {
|
|||||||
self.writer.flush()?;
|
self.writer.flush()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_range(data_dir: PathBuf, source_name: &str, signal_id: &str, from_ns: i64, to_ns: i64, max_points: usize) -> Result<Vec<Sample>> {
|
||||||
|
let mut path = data_dir;
|
||||||
|
path.push(source_name);
|
||||||
|
path.push(signal_id.replace(":", "_").replace("/", "_"));
|
||||||
|
|
||||||
|
if !path.exists() { return Ok(vec![]); }
|
||||||
|
|
||||||
|
let mut all_samples = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.path().extension().and_then(|s| s.to_str()) == Some("bin") {
|
||||||
|
let file = File::open(entry.path())?;
|
||||||
|
let file_len = file.metadata()?.len();
|
||||||
|
let num_samples = (file_len / 16) as usize;
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
for _ in 0..num_samples {
|
||||||
|
let ts = reader.read_i64::<LittleEndian>()?;
|
||||||
|
let val = reader.read_f64::<LittleEndian>()?;
|
||||||
|
if ts >= from_ns && ts <= to_ns {
|
||||||
|
all_samples.push(Sample { timestamp: ts, value: val });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_samples.sort_by_key(|s| s.timestamp);
|
||||||
|
|
||||||
|
if all_samples.len() > max_points && max_points > 0 {
|
||||||
|
let step = all_samples.len() / max_points;
|
||||||
|
Ok(all_samples.into_iter().step_by(step).take(max_points).collect())
|
||||||
|
} else {
|
||||||
|
Ok(all_samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_continuous_storage_creation() {
|
fn test_continuous_storage_creation() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let res = ContinuousStorage::create(
|
let mut storage = ContinuousStorage::create(dir.path().to_path_buf(), "src", "sig", 123).unwrap();
|
||||||
dir.path().to_path_buf(),
|
storage.append(Sample { timestamp: 1, value: 1.0 }).unwrap();
|
||||||
"src",
|
storage.flush().unwrap();
|
||||||
"sig1",
|
|
||||||
12345
|
let samples = ContinuousStorage::read_range(dir.path().to_path_buf(), "src", "sig", 0, 10, 100).unwrap();
|
||||||
);
|
assert_eq!(samples.len(), 1);
|
||||||
assert!(res.is_ok());
|
assert_eq!(samples[0].value, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_continuous_append() {
|
fn test_continuous_append() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let mut storage = ContinuousStorage::create(
|
let mut storage = ContinuousStorage::create(dir.path().to_path_buf(), "s", "i", 1).unwrap();
|
||||||
dir.path().to_path_buf(),
|
assert!(storage.append(Sample { timestamp: 1, value: 1.0 }).is_ok());
|
||||||
"src",
|
|
||||||
"sig1",
|
|
||||||
12345
|
|
||||||
).unwrap();
|
|
||||||
|
|
||||||
let sample = Sample { timestamp: 100, value: 42.0 };
|
|
||||||
storage.append(sample).unwrap();
|
|
||||||
storage.flush().unwrap();
|
|
||||||
|
|
||||||
// Verify file exists and has size 16 bytes (i64 + f64)
|
|
||||||
let path = dir.path().join("src/sig1/12345.bin");
|
|
||||||
assert!(path.exists());
|
|
||||||
assert_eq!(fs::metadata(path).unwrap().len(), 16);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-11
@@ -1,18 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rmon-common"
|
name = "rmon-common"
|
||||||
version.workspace = true
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
bincode.workspace = true
|
serde_json.workspace = true
|
||||||
chrono.workspace = true
|
tokio.workspace = true
|
||||||
thiserror.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
tokio-util.workspace = true
|
tokio-util.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
[dev-dependencies]
|
futures.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
tracing.workspace = true
|
||||||
|
bincode.workspace = true
|
||||||
|
|||||||
+10
-12
@@ -1,24 +1,22 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rmon-ui"
|
name = "rmon-ui"
|
||||||
version.workspace = true
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition = "2021"
|
||||||
authors.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
eframe = "0.24"
|
rmon-common = { path = "../rmon-common" }
|
||||||
egui = "0.24"
|
eframe.workspace = true
|
||||||
egui_plot = "0.24"
|
egui.workspace = true
|
||||||
tokio = { workspace = true, features = ["full"] }
|
egui_plot.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
serde.workspace = true
|
|
||||||
bincode.workspace = true
|
|
||||||
rmon-common.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
tokio-util.workspace = true
|
tokio-util.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
egui_tiles.workspace = true
|
||||||
|
|||||||
+406
-226
@@ -1,13 +1,15 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
|
use eframe::egui::Color32;
|
||||||
use crate::connection::ProtocolClient;
|
use crate::connection::ProtocolClient;
|
||||||
use crate::state::SessionState;
|
use crate::state::{SessionState, SignalHistory, PlotDefinition, SignalStyle};
|
||||||
use crate::state::SignalHistory;
|
|
||||||
use rmon_common::protocol::{ClientMessage, AgentMessage};
|
use rmon_common::protocol::{ClientMessage, AgentMessage};
|
||||||
|
use rmon_common::signal::SignalId;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use egui_plot::{Line, Plot, PlotPoints};
|
use egui_plot::{Line, Plot, PlotPoints, VLine, Legend, Corner};
|
||||||
use std::collections::VecDeque;
|
use std::collections::{VecDeque, HashMap};
|
||||||
|
use chrono::{Utc, Local, TimeZone};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum UiMessage {
|
pub enum UiMessage {
|
||||||
@@ -17,25 +19,37 @@ pub enum UiMessage {
|
|||||||
Disconnected,
|
Disconnected,
|
||||||
Error(String),
|
Error(String),
|
||||||
Log(String),
|
Log(String),
|
||||||
|
RequestInput {
|
||||||
|
prompt: String,
|
||||||
|
reply: Arc<Mutex<Option<oneshot::Sender<String>>>>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RMonApp {
|
pub struct RMonApp {
|
||||||
pub(crate) ssh_host: String,
|
ssh_host: String,
|
||||||
pub(crate) agent_port: u16,
|
agent_port: u16,
|
||||||
pub(crate) addr: String,
|
addr: String,
|
||||||
pub(crate) show_connect_dialog: bool,
|
|
||||||
pub(crate) show_config_editor: bool,
|
show_connect_dialog: bool,
|
||||||
pub(crate) pending_input_request: Option<(String, oneshot::Sender<String>)>,
|
show_config_editor: bool,
|
||||||
pub(crate) input_value: String,
|
show_signals: bool,
|
||||||
pub(crate) config_text: String,
|
edit_mode: bool,
|
||||||
pub(crate) last_error: Option<String>,
|
|
||||||
pub(crate) logs: VecDeque<String>,
|
config_text: String,
|
||||||
pub(crate) state: Arc<Mutex<SessionState>>,
|
last_error: Option<String>,
|
||||||
pub(crate) client: Option<Arc<ProtocolClient>>,
|
logs: VecDeque<String>,
|
||||||
pub(crate) tunnel_child: Option<Arc<Mutex<Option<std::process::Child>>>>,
|
|
||||||
pub(crate) ui_rx: mpsc::Receiver<UiMessage>,
|
pending_input_request: Option<(String, oneshot::Sender<String>)>,
|
||||||
pub(crate) ui_tx: mpsc::Sender<UiMessage>,
|
input_value: String,
|
||||||
pub(crate) selected_signals: Vec<String>,
|
|
||||||
|
// Explicit DND state
|
||||||
|
dragged_signal: Option<(SignalId, String, Color32)>,
|
||||||
|
|
||||||
|
state: Arc<Mutex<SessionState>>,
|
||||||
|
client: Option<Arc<ProtocolClient>>,
|
||||||
|
tunnel_child: Option<Arc<Mutex<Option<std::process::Child>>>>,
|
||||||
|
ui_rx: mpsc::Receiver<UiMessage>,
|
||||||
|
ui_tx: mpsc::Sender<UiMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RMonApp {
|
impl RMonApp {
|
||||||
@@ -47,18 +61,21 @@ impl RMonApp {
|
|||||||
addr: "127.0.0.1:7891".to_string(),
|
addr: "127.0.0.1:7891".to_string(),
|
||||||
show_connect_dialog: false,
|
show_connect_dialog: false,
|
||||||
show_config_editor: false,
|
show_config_editor: false,
|
||||||
pending_input_request: None,
|
show_signals: true,
|
||||||
input_value: String::new(),
|
edit_mode: false,
|
||||||
config_text: String::new(),
|
config_text: String::new(),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
logs: VecDeque::with_capacity(100),
|
logs: VecDeque::with_capacity(100),
|
||||||
|
pending_input_request: None,
|
||||||
|
input_value: String::new(),
|
||||||
|
dragged_signal: None,
|
||||||
state: Arc::new(Mutex::new(SessionState::new())),
|
state: Arc::new(Mutex::new(SessionState::new())),
|
||||||
client: None,
|
client: None,
|
||||||
tunnel_child: None,
|
tunnel_child: None,
|
||||||
ui_rx: rx,
|
ui_rx: rx,
|
||||||
ui_tx: tx,
|
ui_tx: tx,
|
||||||
selected_signals: Vec::new(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_req_{}", app.agent_port)));
|
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_req_{}", app.agent_port)));
|
||||||
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_res_{}", app.agent_port)));
|
let _ = std::fs::remove_file(temp_dir.join(format!("rmon_auth_res_{}", app.agent_port)));
|
||||||
@@ -75,13 +92,10 @@ impl RMonApp {
|
|||||||
let req_path = temp_dir.join(format!("rmon_auth_req_{}", self.agent_port));
|
let req_path = temp_dir.join(format!("rmon_auth_req_{}", self.agent_port));
|
||||||
if req_path.exists() && self.pending_input_request.is_none() {
|
if req_path.exists() && self.pending_input_request.is_none() {
|
||||||
if let Ok(prompt) = std::fs::read_to_string(&req_path) {
|
if let Ok(prompt) = std::fs::read_to_string(&req_path) {
|
||||||
// Delete immediately so we don't re-trigger on next frame
|
|
||||||
let _ = std::fs::remove_file(&req_path);
|
let _ = std::fs::remove_file(&req_path);
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
self.pending_input_request = Some((prompt, tx));
|
self.pending_input_request = Some((prompt, tx));
|
||||||
self.input_value.clear();
|
self.input_value.clear();
|
||||||
|
|
||||||
let port = self.agent_port;
|
let port = self.agent_port;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Ok(val) = rx.await {
|
if let Ok(val) = rx.await {
|
||||||
@@ -94,82 +108,77 @@ impl RMonApp {
|
|||||||
|
|
||||||
while let Ok(msg) = self.ui_rx.try_recv() {
|
while let Ok(msg) = self.ui_rx.try_recv() {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
self.process_ui_message(msg);
|
match msg {
|
||||||
}
|
UiMessage::Log(log) => self.add_log(log),
|
||||||
}
|
UiMessage::Tunnel(child) => {
|
||||||
|
self.add_log("SSH tunnel established".to_string());
|
||||||
fn process_ui_message(&mut self, msg: UiMessage) {
|
self.tunnel_child = Some(child);
|
||||||
match msg {
|
|
||||||
UiMessage::Log(log) => self.add_log(log),
|
|
||||||
UiMessage::Tunnel(child) => {
|
|
||||||
self.add_log("SSH tunnel established".to_string());
|
|
||||||
self.tunnel_child = Some(child);
|
|
||||||
}
|
|
||||||
UiMessage::Connected(client) => {
|
|
||||||
self.add_log("Agent connected".to_string());
|
|
||||||
self.client = Some(client);
|
|
||||||
self.state.lock().unwrap().connected = true;
|
|
||||||
self.show_connect_dialog = false;
|
|
||||||
self.last_error = None;
|
|
||||||
let client_clone = self.client.as_ref().unwrap().clone();
|
|
||||||
tokio::spawn(async move { let _ = client_clone.send(ClientMessage::GetConfig).await; });
|
|
||||||
}
|
|
||||||
UiMessage::Disconnected => {
|
|
||||||
self.add_log("Session ended".to_string());
|
|
||||||
self.client = None;
|
|
||||||
self.state.lock().unwrap().connected = false;
|
|
||||||
if let Some(child_lock) = self.tunnel_child.take() {
|
|
||||||
if let Ok(mut child) = child_lock.lock() {
|
|
||||||
if let Some(mut c) = child.take() { let _ = c.kill(); }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
UiMessage::Connected(client) => {
|
||||||
UiMessage::Error(err) => {
|
self.add_log("Agent connected".to_string());
|
||||||
self.add_log(format!("ERROR: {}", err));
|
self.client = Some(client);
|
||||||
self.last_error = Some(err);
|
let mut state = self.state.lock().unwrap();
|
||||||
}
|
state.connected = true;
|
||||||
UiMessage::Agent(AgentMessage::SignalList { signals }) => {
|
self.show_connect_dialog = false;
|
||||||
self.add_log(format!("Received signal list ({} signals)", signals.len()));
|
self.last_error = None;
|
||||||
let mut state = self.state.lock().unwrap();
|
let client_clone = self.client.as_ref().unwrap().clone();
|
||||||
for sig in signals {
|
tokio::spawn(async move { let _ = client_clone.send(ClientMessage::GetConfig).await; });
|
||||||
state.signals.entry(sig.id.clone()).or_insert_with(|| Arc::new(Mutex::new(SignalHistory::new(sig))));
|
|
||||||
}
|
}
|
||||||
}
|
UiMessage::Disconnected => {
|
||||||
UiMessage::Agent(AgentMessage::ConfigReport { config_toml }) => { self.config_text = config_toml; }
|
self.add_log("Session ended".to_string());
|
||||||
UiMessage::Agent(AgentMessage::DataBatch { batches }) => {
|
self.client = None;
|
||||||
let mut new_ids = Vec::new();
|
self.state.lock().unwrap().connected = false;
|
||||||
{
|
if let Some(child_lock) = self.tunnel_child.take() {
|
||||||
let state = self.state.lock().unwrap();
|
if let Ok(mut child) = child_lock.lock() {
|
||||||
for batch in batches {
|
if let Some(mut c) = child.take() { let _ = c.kill(); }
|
||||||
if let Some(history) = state.signals.get(&batch.id) {
|
|
||||||
let mut history = history.lock().unwrap();
|
|
||||||
let is_first = history.samples.is_empty();
|
|
||||||
for sample in batch.samples { history.push(sample); }
|
|
||||||
if is_first { new_ids.push(batch.id.clone()); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id in new_ids { self.add_log(format!("Data start for {}", id)); }
|
UiMessage::Error(err) => {
|
||||||
|
self.add_log(format!("ERROR: {}", err));
|
||||||
|
self.last_error = Some(err);
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::SignalList { signals }) => {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
for sig in signals {
|
||||||
|
state.signals.entry(sig.id.clone()).or_insert_with(|| Arc::new(Mutex::new(SignalHistory::new(sig))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::ConfigReport { config_toml }) => { self.config_text = config_toml; }
|
||||||
|
UiMessage::Agent(AgentMessage::DataBatch { batches }) => {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
for batch in batches {
|
||||||
|
if let Some(history_lock) = state.signals.get(&batch.id) {
|
||||||
|
let mut history = history_lock.lock().unwrap();
|
||||||
|
for sample in batch.samples { history.push(sample); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UiMessage::Agent(AgentMessage::HistoryChunk { id, samples, .. }) => {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
if let Some(history_lock) = state.signals.get(&id) {
|
||||||
|
let mut history = history_lock.lock().unwrap();
|
||||||
|
for sample in samples { history.push(sample); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
UiMessage::Agent(AgentMessage::Error { context, message }) => { self.add_log(format!("AGENT ERROR ({}): {}", context, message)); }
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_auth_dialog(&mut self, ctx: &egui::Context) {
|
fn draw_auth_dialog(&mut self, ctx: &egui::Context) {
|
||||||
if self.pending_input_request.is_some() {
|
if let Some((prompt, _)) = &self.pending_input_request {
|
||||||
let prompt = self.pending_input_request.as_ref().unwrap().0.clone();
|
|
||||||
let mut open = true;
|
let mut open = true;
|
||||||
egui::Window::new("SSH Authentication Required").open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
|
let prompt_clone = prompt.clone();
|
||||||
ui.label(egui::RichText::new(&prompt).strong());
|
egui::Window::new("SSH Authentication Required").open(&mut open).show(ctx, |ui| {
|
||||||
ui.add_space(8.0);
|
ui.label(egui::RichText::new(&prompt_clone).strong());
|
||||||
let is_password = prompt.to_lowercase().contains("password");
|
let is_password = prompt_clone.to_lowercase().contains("password");
|
||||||
let text_edit = egui::TextEdit::singleline(&mut self.input_value).password(is_password);
|
let response = ui.add(egui::TextEdit::singleline(&mut self.input_value).password(is_password));
|
||||||
let response = ui.add(text_edit);
|
|
||||||
response.request_focus();
|
response.request_focus();
|
||||||
ui.add_space(10.0);
|
|
||||||
if ui.button("Confirm").clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
if ui.button("Confirm").clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||||
if let Some((_, tx)) = self.pending_input_request.take() { let _ = tx.send(self.input_value.clone()); }
|
if let Some((_, tx)) = self.pending_input_request.take() {
|
||||||
|
let _ = tx.send(self.input_value.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !open { self.pending_input_request = None; }
|
if !open { self.pending_input_request = None; }
|
||||||
@@ -189,38 +198,56 @@ impl RMonApp {
|
|||||||
let (ssh_host, agent_port, addr, ui_tx) = (self.ssh_host.clone(), self.agent_port, self.addr.clone(), self.ui_tx.clone());
|
let (ssh_host, agent_port, addr, ui_tx) = (self.ssh_host.clone(), self.agent_port, self.addr.clone(), self.ui_tx.clone());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
use crate::connection::SshConnection;
|
use crate::connection::SshConnection;
|
||||||
|
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Checking for existing connection...".to_string())).await;
|
||||||
|
|
||||||
|
// 1. Speculative connect (skip SSH if port already forwarded and agent alive)
|
||||||
|
if let Ok(client) = ProtocolClient::connect(&addr, ui_tx.clone()).await {
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Reusing existing tunnel/port".to_string())).await;
|
||||||
|
let _ = ui_tx.send(UiMessage::Connected(Arc::new(client))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let ssh = SshConnection::new(ssh_host.clone(), agent_port);
|
let ssh = SshConnection::new(ssh_host.clone(), agent_port);
|
||||||
let is_localhost = ssh_host == "localhost" || ssh_host == "127.0.0.1";
|
let is_localhost = ssh_host == "localhost" || ssh_host == "127.0.0.1";
|
||||||
let local_port = addr.split(':').last().and_then(|p| p.parse::<u16>().ok()).unwrap_or(7891);
|
let local_port = addr.split(':').last().and_then(|p| p.parse::<u16>().ok()).unwrap_or(7891);
|
||||||
let skip_tunnel = is_localhost && local_port == agent_port;
|
let skip_tunnel = is_localhost && local_port == agent_port;
|
||||||
let _ = ui_tx.send(UiMessage::Log("Preparing...".to_string())).await;
|
|
||||||
|
let _ = ui_tx.send(UiMessage::Log("Preparing SSH flow...".to_string())).await;
|
||||||
let _ = ssh.kill_local_orphan_tunnels();
|
let _ = ssh.kill_local_orphan_tunnels();
|
||||||
if let Err(e) = ssh.establish_master() { let _ = ui_tx.send(UiMessage::Error(format!("SSH Auth failed: {}", e))).await; return; }
|
if let Err(e) = ssh.establish_master() { let _ = ui_tx.send(UiMessage::Error(format!("SSH Auth failed: {}", e))).await; return; }
|
||||||
match ssh.check_running() {
|
match ssh.check_running() {
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
let _ = ui_tx.send(UiMessage::Log("Deploying and starting agent...".to_string())).await;
|
let _ = ui_tx.send(UiMessage::Log("Starting agent...".to_string())).await;
|
||||||
if let Err(e) = ssh.start_agent() { let _ = ui_tx.send(UiMessage::Error(format!("Start failed: {}", e))).await; return; }
|
if let Err(e) = ssh.start_agent() { let _ = ui_tx.send(UiMessage::Error(format!("Start failed: {}", e))).await; return; }
|
||||||
sleep(std::time::Duration::from_secs(1)).await;
|
sleep(std::time::Duration::from_secs(1)).await;
|
||||||
}
|
}
|
||||||
_ => { let _ = ui_tx.send(UiMessage::Log("Agent already running".to_string())).await; }
|
_ => { let _ = ui_tx.send(UiMessage::Log("Agent already running".to_string())).await; }
|
||||||
}
|
}
|
||||||
if !skip_tunnel {
|
if !skip_tunnel {
|
||||||
let _ = ui_tx.send(UiMessage::Log("Establishing SSH tunnel...".to_string())).await;
|
|
||||||
match ssh.create_tunnel() {
|
match ssh.create_tunnel() {
|
||||||
Ok(child) => { let _ = ui_tx.send(UiMessage::Tunnel(Arc::new(Mutex::new(Some(child))))).await; sleep(std::time::Duration::from_secs(1)).await; }
|
Ok(child) => { let _ = ui_tx.send(UiMessage::Tunnel(Arc::new(Mutex::new(Some(child))))).await; sleep(std::time::Duration::from_secs(1)).await; }
|
||||||
Err(e) => { let _ = ui_tx.send(UiMessage::Error(format!("Tunnel failed: {}", e))).await; return; }
|
Err(e) => { let _ = ui_tx.send(UiMessage::Error(format!("Tunnel failed: {}", e))).await; return; }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
let _ = ui_tx.send(UiMessage::Log("Direct local connection".to_string())).await;
|
|
||||||
}
|
}
|
||||||
match ProtocolClient::connect(&addr, ui_tx.clone()).await {
|
|
||||||
Ok(client) => { let _ = ui_tx.send(UiMessage::Connected(Arc::new(client))).await; }
|
let mut last_err = String::new();
|
||||||
Err(e) => {
|
for attempt in 1..=5 {
|
||||||
let _ = ui_tx.send(UiMessage::Error(format!("Handshake failed: {}", e))).await;
|
let _ = ui_tx.send(UiMessage::Log(format!("Connection attempt {}/5...", attempt))).await;
|
||||||
let _ = ssh.close_master();
|
match ProtocolClient::connect(&addr, ui_tx.clone()).await {
|
||||||
let _ = ui_tx.send(UiMessage::Disconnected).await;
|
Ok(client) => {
|
||||||
|
let _ = ui_tx.send(UiMessage::Connected(Arc::new(client))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
last_err = e.to_string();
|
||||||
|
sleep(std::time::Duration::from_millis(500 * attempt as u64)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let _ = ui_tx.send(UiMessage::Error(format!("Failed to connect: {}", last_err))).await;
|
||||||
|
let _ = ssh.close_master();
|
||||||
|
let _ = ui_tx.send(UiMessage::Disconnected).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -228,11 +255,280 @@ impl RMonApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TreeBehavior<'a> {
|
||||||
|
signals: HashMap<SignalId, Arc<Mutex<SignalHistory>>>,
|
||||||
|
styles: &'a mut HashMap<SignalId, SignalStyle>,
|
||||||
|
client: &'a Option<Arc<ProtocolClient>>,
|
||||||
|
edit_mode: bool,
|
||||||
|
dragged_signal: &'a Option<(SignalId, String, Color32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> egui_tiles::Behavior<PlotDefinition> for TreeBehavior<'a> {
|
||||||
|
fn tab_title_for_pane(&mut self, plot_def: &PlotDefinition) -> egui::WidgetText {
|
||||||
|
plot_def.title.clone().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane_ui(&mut self, ui: &mut egui::Ui, _tile_id: egui_tiles::TileId, plot_def: &mut PlotDefinition) -> egui_tiles::UiResponse {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
// Toolbar
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if self.edit_mode {
|
||||||
|
ui.label(egui::RichText::new(&plot_def.title).strong());
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.checkbox(&mut plot_def.cursors[0].active, "C1");
|
||||||
|
ui.checkbox(&mut plot_def.cursors[1].active, "C2");
|
||||||
|
|
||||||
|
if plot_def.cursors[0].active && plot_def.cursors[1].active {
|
||||||
|
let dt = (plot_def.cursors[1].x - plot_def.cursors[0].x).abs();
|
||||||
|
ui.label(format!("ΔT: {:.3}s", dt));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.toggle_value(&mut plot_def.follow_mode, "Live");
|
||||||
|
if plot_def.follow_mode {
|
||||||
|
ui.add(egui::DragValue::new(&mut plot_def.time_window).suffix("s").range(1.0..=3600.0));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate latest data timestamp
|
||||||
|
let mut latest_ts: f64 = 0.0;
|
||||||
|
for id in &plot_def.signal_ids {
|
||||||
|
if let Some(history_lock) = self.signals.get(id) {
|
||||||
|
let history = history_lock.lock().unwrap();
|
||||||
|
if let Some(last) = history.samples.back() {
|
||||||
|
latest_ts = latest_ts.max(last.timestamp as f64 / 1e9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Mouse Wheel Zoom for time_window
|
||||||
|
if plot_def.follow_mode && ui.rect_contains_pointer(ui.max_rect()) {
|
||||||
|
let scroll = ui.input(|i| i.raw_scroll_delta.y);
|
||||||
|
if scroll != 0.0 {
|
||||||
|
let zoom_factor = if scroll > 0.0 { 0.9 } else { 1.1 };
|
||||||
|
plot_def.time_window = (plot_def.time_window * zoom_factor).clamp(1.0, 3600.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut plot = Plot::new(&plot_def.id)
|
||||||
|
.link_axis(egui::Id::new("shared_x"), true, false)
|
||||||
|
.link_cursor(egui::Id::new("shared_cursor"), true, false)
|
||||||
|
.legend(Legend::default().position(Corner::LeftTop))
|
||||||
|
.x_axis_formatter(|mark, _| {
|
||||||
|
let seconds = mark.value;
|
||||||
|
let dt = Local.timestamp_opt(seconds as i64, (seconds.fract() * 1e9) as u32).unwrap();
|
||||||
|
dt.format("%H:%M:%S").to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
if plot_def.follow_mode && latest_ts > 0.0 {
|
||||||
|
plot = plot.include_x(latest_ts)
|
||||||
|
.include_x(latest_ts - plot_def.time_window);
|
||||||
|
} else {
|
||||||
|
plot = plot.auto_bounds(egui::Vec2b::new(true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
let plot_res = plot.show(ui, |plot_ui| {
|
||||||
|
for id in &plot_def.signal_ids {
|
||||||
|
if let Some(history_lock) = self.signals.get(id) {
|
||||||
|
let history = history_lock.lock().unwrap();
|
||||||
|
if !history.samples.is_empty() {
|
||||||
|
let points: PlotPoints = history.samples.iter()
|
||||||
|
.map(|s| [s.timestamp as f64 / 1e9, s.value])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let style = self.styles.get(id).cloned().unwrap_or_default();
|
||||||
|
plot_ui.line(Line::new(points).color(style.color).width(style.line_width).name(&history.info.label));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, cursor) in plot_def.cursors.iter().enumerate() {
|
||||||
|
if cursor.active {
|
||||||
|
let color = if i == 0 { Color32::KHAKI } else { Color32::LIGHT_BLUE };
|
||||||
|
plot_ui.vline(VLine::new(cursor.x).color(color).width(1.5).name(format!("C{}", i+1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||||||
|
let val_pos = plot_res.transform.value_from_position(pointer_pos);
|
||||||
|
|
||||||
|
// 1. Dragging Cursors
|
||||||
|
for i in 0..2 {
|
||||||
|
if plot_def.cursors[i].active {
|
||||||
|
let dist_px = (plot_res.transform.position_from_point(&egui_plot::PlotPoint::new(plot_def.cursors[i].x, 0.0)).x - pointer_pos.x).abs();
|
||||||
|
|
||||||
|
let drag_id = egui::Id::new(&plot_def.id).with("cursor").with(i);
|
||||||
|
if ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary)) && dist_px < 15.0 {
|
||||||
|
ui.ctx().set_dragged_id(drag_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.ctx().is_being_dragged(drag_id) {
|
||||||
|
plot_def.cursors[i].x = val_pos.x;
|
||||||
|
if ui.input(|i| i.pointer.any_released()) {
|
||||||
|
ui.ctx().stop_dragging();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Signal Drop Detection
|
||||||
|
if ui.rect_contains_pointer(plot_res.response.rect) {
|
||||||
|
if let Some((sig_id, _, _)) = self.dragged_signal {
|
||||||
|
if ui.input(|i| i.pointer.any_released()) {
|
||||||
|
if !plot_def.signal_ids.contains(sig_id) {
|
||||||
|
plot_def.signal_ids.push(sig_id.clone());
|
||||||
|
if let Some(client) = self.client {
|
||||||
|
let client = client.clone();
|
||||||
|
let sig_id_clone = sig_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = client.send(ClientMessage::Subscribe { ids: vec![sig_id_clone], from_ns: None }).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.edit_mode {
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
let mut to_remove = None;
|
||||||
|
for (idx, id) in plot_def.signal_ids.iter().enumerate() {
|
||||||
|
if let Some(history_lock) = self.signals.get(id) {
|
||||||
|
let history = history_lock.lock().unwrap();
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label(&history.info.label);
|
||||||
|
let style = self.styles.entry(id.clone()).or_insert(SignalStyle::default());
|
||||||
|
ui.color_edit_button_srgba(&mut style.color);
|
||||||
|
if ui.button("❌").clicked() { to_remove = Some(idx); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(idx) = to_remove { plot_def.signal_ids.remove(idx); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
egui_tiles::UiResponse::None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl eframe::App for RMonApp {
|
impl eframe::App for RMonApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.update_from_agent(ctx);
|
self.update_from_agent(ctx);
|
||||||
if self.pending_input_request.is_some() { self.draw_auth_dialog(ctx); }
|
if self.pending_input_request.is_some() { self.draw_auth_dialog(ctx); }
|
||||||
if self.show_connect_dialog { self.draw_connect_dialog(ctx); }
|
if self.show_connect_dialog { self.draw_connect_dialog(ctx); }
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
||||||
|
egui::menu::bar(ui, |ui| {
|
||||||
|
ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } });
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let connected = self.client.is_some();
|
||||||
|
if !connected {
|
||||||
|
if ui.button("🚀 Connect...").clicked() { self.last_error = None; self.show_connect_dialog = true; }
|
||||||
|
} else {
|
||||||
|
if ui.button("🔌 Disconnect").clicked() { let _ = self.ui_tx.try_send(UiMessage::Disconnected); }
|
||||||
|
if ui.button("⚙ Config").clicked() { self.show_config_editor = true; }
|
||||||
|
ui.toggle_value(&mut self.show_signals, "📡 Signals");
|
||||||
|
ui.toggle_value(&mut self.edit_mode, "🛠 Edit Layout");
|
||||||
|
|
||||||
|
if ui.button("⌛ Load 1h").clicked() {
|
||||||
|
if let Some(client) = &self.client {
|
||||||
|
let (client, state) = (client.clone(), self.state.lock().unwrap());
|
||||||
|
let ids: Vec<_> = state.signals.keys().cloned().collect();
|
||||||
|
let to_ns = Utc::now().timestamp_nanos_opt().unwrap_or(0);
|
||||||
|
let from_ns = to_ns - 3600 * 1_000_000_000;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for id in ids { let _ = client.send(ClientMessage::QueryHistory { id, from_ns, to_ns, max_points: 1000 }).await; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if self.show_signals {
|
||||||
|
egui::SidePanel::left("signals_panel").resizable(true).show(ctx, |ui| {
|
||||||
|
ui.heading("Signals");
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
let mut ids: Vec<_> = state.signals.keys().cloned().collect();
|
||||||
|
ids.sort();
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
for id in ids {
|
||||||
|
let label = {
|
||||||
|
let history_lock = state.signals.get(&id).unwrap();
|
||||||
|
let history = history_lock.lock().unwrap();
|
||||||
|
history.info.label.clone()
|
||||||
|
};
|
||||||
|
let style = state.get_style(&id);
|
||||||
|
|
||||||
|
let response = ui.add(egui::Label::new(egui::RichText::new(format!("⬌ {}", label)).color(style.color)).sense(egui::Sense::drag()));
|
||||||
|
if response.drag_started() {
|
||||||
|
ui.ctx().set_dragged_id(egui::Id::new("drag_signal"));
|
||||||
|
self.dragged_signal = Some((id.clone(), label.clone(), style.color));
|
||||||
|
}
|
||||||
|
response.on_hover_text("Drag to a plot to add");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::TopBottomPanel::bottom("log_panel").resizable(true).default_height(40.0).show(ctx, |ui| {
|
||||||
|
egui::ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
||||||
|
for log in &self.logs { ui.label(egui::RichText::new(log).monospace().size(9.0)); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
let mut tree = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
std::mem::replace(&mut state.tree.clone(), egui_tiles::Tree::empty("tmp"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let (signals, mut signal_styles) = {
|
||||||
|
let state = self.state.lock().unwrap();
|
||||||
|
(state.signals.clone(), state.signal_styles.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut behavior = TreeBehavior {
|
||||||
|
signals,
|
||||||
|
styles: &mut signal_styles,
|
||||||
|
client: &self.client,
|
||||||
|
edit_mode: self.edit_mode,
|
||||||
|
dragged_signal: &self.dragged_signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
tree.ui(&mut behavior, ui);
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.signal_styles = signal_styles;
|
||||||
|
state.tree = tree;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some((_, label, color)) = &self.dragged_signal {
|
||||||
|
if ctx.is_being_dragged(egui::Id::new("drag_signal")) {
|
||||||
|
if let Some(pos) = ctx.pointer_interact_pos() {
|
||||||
|
egui::Area::new(egui::Id::new("drag_ghost"))
|
||||||
|
.fixed_pos(pos + egui::vec2(15.0, 15.0))
|
||||||
|
.order(egui::Order::Tooltip)
|
||||||
|
.interactable(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.label(egui::RichText::new(format!("Adding: {}", label)).background_color(egui::Color32::from_black_alpha(180)).color(*color).strong());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.input(|i| i.pointer.any_released()) {
|
||||||
|
self.dragged_signal = None;
|
||||||
|
}
|
||||||
|
|
||||||
if self.show_config_editor {
|
if self.show_config_editor {
|
||||||
let mut open = self.show_config_editor;
|
let mut open = self.show_config_editor;
|
||||||
egui::Window::new("Agent Configuration").open(&mut open).show(ctx, |ui| {
|
egui::Window::new("Agent Configuration").open(&mut open).show(ctx, |ui| {
|
||||||
@@ -243,16 +539,7 @@ impl eframe::App for RMonApp {
|
|||||||
let config_toml = self.config_text.clone();
|
let config_toml = self.config_text.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = client.send(ClientMessage::UpdateConfig { config_toml }).await;
|
let _ = client.send(ClientMessage::UpdateConfig { config_toml }).await;
|
||||||
sleep(std::time::Duration::from_millis(1500)).await;
|
sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
let _ = client.send(ClientMessage::ListSignals).await;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ui.button("🔄 Refresh").clicked() {
|
|
||||||
if let Some(client) = &self.client {
|
|
||||||
let client = client.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = client.send(ClientMessage::GetConfig).await;
|
|
||||||
let _ = client.send(ClientMessage::ListSignals).await;
|
let _ = client.send(ClientMessage::ListSignals).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -260,82 +547,16 @@ impl eframe::App for RMonApp {
|
|||||||
});
|
});
|
||||||
self.show_config_editor = open;
|
self.show_config_editor = open;
|
||||||
}
|
}
|
||||||
egui::TopBottomPanel::top("top_panel").show(ctx, |ui| {
|
|
||||||
egui::menu::bar(ui, |ui| {
|
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||||
ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } });
|
|
||||||
ui.separator();
|
|
||||||
if self.client.is_none() {
|
|
||||||
if ui.button("🚀 Connect...").clicked() { self.last_error = None; self.show_connect_dialog = true; }
|
|
||||||
} else {
|
|
||||||
if ui.button("🔌 Disconnect").clicked() { let _ = self.ui_tx.try_send(UiMessage::Disconnected); }
|
|
||||||
if ui.button("💀 Kill Agent").clicked() {
|
|
||||||
let (host, port, tx) = (self.ssh_host.clone(), self.agent_port, self.ui_tx.clone());
|
|
||||||
tokio::spawn(async move {
|
|
||||||
use crate::connection::SshConnection;
|
|
||||||
let ssh = SshConnection::new(host, port);
|
|
||||||
let _ = tx.send(UiMessage::Log("Killing agent...".to_string())).await;
|
|
||||||
let _ = ssh.kill_agent();
|
|
||||||
});
|
|
||||||
let _ = self.ui_tx.try_send(UiMessage::Disconnected);
|
|
||||||
}
|
|
||||||
if ui.button("⚙ Configure Agent").clicked() { self.show_config_editor = true; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
egui::TopBottomPanel::bottom("log_panel").resizable(true).default_height(100.0).show(ctx, |ui| {
|
|
||||||
ui.heading("Logs");
|
|
||||||
egui::ScrollArea::vertical().stick_to_bottom(true).show(ui, |ui| {
|
|
||||||
for log in &self.logs { ui.label(egui::RichText::new(log).monospace()); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
egui::SidePanel::left("left_panel").show(ctx, |ui| {
|
|
||||||
ui.heading("Signals");
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
let mut ids: Vec<_> = state.signals.keys().cloned().collect();
|
|
||||||
ids.sort();
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
for id in ids {
|
|
||||||
if let Some(history) = state.signals.get(&id) {
|
|
||||||
let history = history.lock().unwrap();
|
|
||||||
let mut checked = self.selected_signals.contains(&id);
|
|
||||||
if ui.checkbox(&mut checked, &history.info.label).changed() {
|
|
||||||
if checked {
|
|
||||||
self.selected_signals.push(id.clone());
|
|
||||||
if let Some(client) = &self.client {
|
|
||||||
let client = client.clone();
|
|
||||||
tokio::spawn(async move { let _ = client.send(ClientMessage::Subscribe { ids: vec![id], from_ns: None }).await; });
|
|
||||||
}
|
|
||||||
} else { self.selected_signals.retain(|s| s != &id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
|
||||||
ui.heading("Plots");
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
for id in &self.selected_signals {
|
|
||||||
if let Some(history) = state.signals.get(id) {
|
|
||||||
let history = history.lock().unwrap();
|
|
||||||
ui.label(format!("{} [{}]", history.info.label, history.samples.len()));
|
|
||||||
if !history.samples.is_empty() {
|
|
||||||
let first_ts = history.samples[0].timestamp as f64 / 1e9;
|
|
||||||
let points: PlotPoints = history.samples.iter().map(|s| [(s.timestamp as f64 / 1e9) - first_ts, s.value]).collect();
|
|
||||||
Plot::new(id).view_aspect(2.5).auto_bounds_x().auto_bounds_y().show(ui, |plot_ui| plot_ui.line(Line::new(points)));
|
|
||||||
} else { ui.label("Waiting for data..."); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use rmon_common::signal::{SignalInfo, SourceType};
|
use rmon_common::signal::SourceType;
|
||||||
|
use rmon_common::signal::SignalInfo;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_app_init() {
|
fn test_app_init() {
|
||||||
@@ -346,61 +567,20 @@ mod tests {
|
|||||||
addr: "a".to_string(),
|
addr: "a".to_string(),
|
||||||
show_connect_dialog: false,
|
show_connect_dialog: false,
|
||||||
show_config_editor: false,
|
show_config_editor: false,
|
||||||
pending_input_request: None,
|
show_signals: true,
|
||||||
input_value: "".to_string(),
|
edit_mode: false,
|
||||||
config_text: "".to_string(),
|
config_text: "".to_string(),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
logs: VecDeque::new(),
|
logs: VecDeque::new(),
|
||||||
|
pending_input_request: None,
|
||||||
|
input_value: "".to_string(),
|
||||||
|
dragged_signal: None,
|
||||||
state: Arc::new(Mutex::new(SessionState::new())),
|
state: Arc::new(Mutex::new(SessionState::new())),
|
||||||
client: None,
|
client: None,
|
||||||
tunnel_child: None,
|
tunnel_child: None,
|
||||||
ui_rx: rx,
|
ui_rx: rx,
|
||||||
ui_tx: tx,
|
ui_tx: tx,
|
||||||
selected_signals: vec![],
|
|
||||||
};
|
};
|
||||||
assert_eq!(app.ssh_host, "h");
|
assert_eq!(app.ssh_host, "h");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_process_ui_message_logic() {
|
|
||||||
let (tx, rx) = mpsc::channel(100);
|
|
||||||
let mut app = RMonApp {
|
|
||||||
ssh_host: "h".to_string(),
|
|
||||||
agent_port: 1,
|
|
||||||
addr: "a".to_string(),
|
|
||||||
show_connect_dialog: false,
|
|
||||||
show_config_editor: false,
|
|
||||||
pending_input_request: None,
|
|
||||||
input_value: "".to_string(),
|
|
||||||
config_text: "".to_string(),
|
|
||||||
last_error: None,
|
|
||||||
logs: VecDeque::new(),
|
|
||||||
state: Arc::new(Mutex::new(SessionState::new())),
|
|
||||||
client: None,
|
|
||||||
tunnel_child: None,
|
|
||||||
ui_rx: rx,
|
|
||||||
ui_tx: tx,
|
|
||||||
selected_signals: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
app.process_ui_message(UiMessage::Log("test log".to_string()));
|
|
||||||
assert_eq!(app.logs.len(), 1);
|
|
||||||
assert_eq!(app.logs[0], "test log");
|
|
||||||
|
|
||||||
app.process_ui_message(UiMessage::Error("test err".to_string()));
|
|
||||||
assert_eq!(app.last_error.as_ref().unwrap(), "test err");
|
|
||||||
|
|
||||||
let sig = SignalInfo {
|
|
||||||
id: "s1".to_string(), label: "L".to_string(), unit: "V".to_string(), scale: 1.0, offset: 0.0,
|
|
||||||
sampling_period: None, source_type: SourceType::Csv, path: vec![],
|
|
||||||
};
|
|
||||||
app.process_ui_message(UiMessage::Agent(AgentMessage::SignalList { signals: vec![sig] }));
|
|
||||||
assert_eq!(app.state.lock().unwrap().signals.len(), 1);
|
|
||||||
|
|
||||||
app.process_ui_message(UiMessage::Agent(AgentMessage::ConfigReport { config_toml: "cfg".to_string() }));
|
|
||||||
assert_eq!(app.config_text, "cfg");
|
|
||||||
|
|
||||||
app.process_ui_message(UiMessage::Disconnected);
|
|
||||||
assert!(!app.state.lock().unwrap().connected);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -64,6 +64,6 @@ fn main() -> eframe::Result<()> {
|
|||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"RMon",
|
"RMon",
|
||||||
native_options,
|
native_options,
|
||||||
Box::new(|cc| Box::new(RMonApp::new(cc))),
|
Box::new(|cc| Ok(Box::new(RMonApp::new(cc)))),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-39
@@ -1,6 +1,7 @@
|
|||||||
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
use rmon_common::signal::{SignalId, SignalInfo, Sample};
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use eframe::egui::ecolor::Color32;
|
||||||
|
|
||||||
pub struct SignalHistory {
|
pub struct SignalHistory {
|
||||||
pub info: SignalInfo,
|
pub info: SignalInfo,
|
||||||
@@ -13,7 +14,7 @@ impl SignalHistory {
|
|||||||
Self {
|
Self {
|
||||||
info,
|
info,
|
||||||
samples: VecDeque::new(),
|
samples: VecDeque::new(),
|
||||||
max_samples: 1000, // Default for display
|
max_samples: 10000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,52 +26,95 @@ impl SignalHistory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SessionState {
|
#[derive(Clone)]
|
||||||
pub connected: bool,
|
pub struct SignalStyle {
|
||||||
pub signals: HashMap<SignalId, Arc<Mutex<SignalHistory>>>,
|
pub color: Color32,
|
||||||
|
pub line_width: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionState {
|
impl Default for SignalStyle {
|
||||||
pub fn new() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
connected: false,
|
color: Color32::WHITE,
|
||||||
signals: HashMap::new(),
|
line_width: 1.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[derive(Clone, Default)]
|
||||||
mod tests {
|
pub struct PlotCursor {
|
||||||
use super::*;
|
pub x: f64,
|
||||||
use rmon_common::signal::SourceType;
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[derive(Clone)]
|
||||||
fn test_signal_history_push() {
|
pub struct PlotDefinition {
|
||||||
let info = SignalInfo {
|
pub id: String,
|
||||||
id: "test://1".to_string(),
|
pub title: String,
|
||||||
label: "L".to_string(),
|
pub signal_ids: Vec<SignalId>,
|
||||||
unit: "U".to_string(),
|
pub cursors: [PlotCursor; 2],
|
||||||
scale: 1.0,
|
pub show_legend: bool,
|
||||||
offset: 0.0,
|
pub follow_mode: bool,
|
||||||
sampling_period: None,
|
pub time_window: f64, // seconds
|
||||||
source_type: SourceType::Csv,
|
}
|
||||||
path: vec![],
|
|
||||||
};
|
|
||||||
let mut history = SignalHistory::new(info);
|
|
||||||
history.max_samples = 2;
|
|
||||||
|
|
||||||
history.push(Sample { timestamp: 1, value: 1.0 });
|
impl PlotDefinition {
|
||||||
history.push(Sample { timestamp: 2, value: 2.0 });
|
pub fn new(title: String) -> Self {
|
||||||
history.push(Sample { timestamp: 3, value: 3.0 });
|
Self {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
assert_eq!(history.samples.len(), 2);
|
title,
|
||||||
assert_eq!(history.samples[0].timestamp, 2);
|
signal_ids: Vec::new(),
|
||||||
}
|
cursors: [PlotCursor::default(), PlotCursor::default()],
|
||||||
|
show_legend: true,
|
||||||
#[test]
|
follow_mode: true,
|
||||||
fn test_session_state_init() {
|
time_window: 600.0, // Default to 10 minutes
|
||||||
let state = SessionState::new();
|
}
|
||||||
assert!(!state.connected);
|
}
|
||||||
assert!(state.signals.is_empty());
|
}
|
||||||
|
|
||||||
|
pub struct SessionState {
|
||||||
|
pub connected: bool,
|
||||||
|
pub signals: HashMap<SignalId, Arc<Mutex<SignalHistory>>>,
|
||||||
|
pub tree: egui_tiles::Tree<PlotDefinition>,
|
||||||
|
pub signal_styles: HashMap<SignalId, SignalStyle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut tiles = egui_tiles::Tiles::default();
|
||||||
|
let root = tiles.insert_pane(PlotDefinition::new("Main Plot".to_string()));
|
||||||
|
let tree = egui_tiles::Tree::new("main_tree", root, tiles);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
connected: false,
|
||||||
|
signals: HashMap::new(),
|
||||||
|
tree,
|
||||||
|
signal_styles: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_style(&mut self, id: &SignalId) -> SignalStyle {
|
||||||
|
if let Some(style) = self.signal_styles.get(id) {
|
||||||
|
return style.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let colors = [
|
||||||
|
Color32::from_rgb(255, 80, 80),
|
||||||
|
Color32::from_rgb(80, 255, 80),
|
||||||
|
Color32::from_rgb(80, 80, 255),
|
||||||
|
Color32::from_rgb(255, 255, 80),
|
||||||
|
Color32::from_rgb(255, 80, 255),
|
||||||
|
Color32::from_rgb(80, 255, 255),
|
||||||
|
Color32::from_rgb(255, 150, 50),
|
||||||
|
Color32::from_rgb(150, 80, 255),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut hash = 0u64;
|
||||||
|
for b in id.as_bytes() { hash = hash.wrapping_add(*b as u64); }
|
||||||
|
let color = colors[(hash % colors.len() as u64) as usize];
|
||||||
|
|
||||||
|
let style = SignalStyle { color, line_width: 1.5 };
|
||||||
|
self.signal_styles.insert(id.clone(), style.clone());
|
||||||
|
style
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user