Initial working implementation (MVP)

This commit is contained in:
Martino Ferrari
2026-04-11 21:11:15 +02:00
parent dca378b5ef
commit 1a39b3a54e
35 changed files with 7457 additions and 147 deletions
+406
View File
@@ -0,0 +1,406 @@
use eframe::egui;
use crate::connection::ProtocolClient;
use crate::state::SessionState;
use crate::state::SignalHistory;
use rmon_common::protocol::{ClientMessage, AgentMessage};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio::time::sleep;
use egui_plot::{Line, Plot, PlotPoints};
use std::collections::VecDeque;
#[derive(Clone)]
pub enum UiMessage {
Agent(AgentMessage),
Connected(Arc<ProtocolClient>),
Tunnel(Arc<Mutex<Option<std::process::Child>>>),
Disconnected,
Error(String),
Log(String),
}
pub struct RMonApp {
pub(crate) ssh_host: String,
pub(crate) agent_port: u16,
pub(crate) addr: String,
pub(crate) show_connect_dialog: bool,
pub(crate) show_config_editor: bool,
pub(crate) pending_input_request: Option<(String, oneshot::Sender<String>)>,
pub(crate) input_value: String,
pub(crate) config_text: String,
pub(crate) last_error: Option<String>,
pub(crate) logs: VecDeque<String>,
pub(crate) state: Arc<Mutex<SessionState>>,
pub(crate) client: Option<Arc<ProtocolClient>>,
pub(crate) tunnel_child: Option<Arc<Mutex<Option<std::process::Child>>>>,
pub(crate) ui_rx: mpsc::Receiver<UiMessage>,
pub(crate) ui_tx: mpsc::Sender<UiMessage>,
pub(crate) selected_signals: Vec<String>,
}
impl RMonApp {
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
let (tx, rx) = mpsc::channel(100);
let app = Self {
ssh_host: "localhost".to_string(),
agent_port: 7891,
addr: "127.0.0.1:7891".to_string(),
show_connect_dialog: false,
show_config_editor: false,
pending_input_request: None,
input_value: String::new(),
config_text: String::new(),
last_error: None,
logs: VecDeque::with_capacity(100),
state: Arc::new(Mutex::new(SessionState::new())),
client: None,
tunnel_child: None,
ui_rx: rx,
ui_tx: tx,
selected_signals: Vec::new(),
};
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_res_{}", app.agent_port)));
app
}
fn add_log(&mut self, msg: String) {
if self.logs.len() >= 100 { self.logs.pop_front(); }
self.logs.push_back(msg);
}
fn update_from_agent(&mut self, ctx: &egui::Context) {
let temp_dir = std::env::temp_dir();
let req_path = temp_dir.join(format!("rmon_auth_req_{}", self.agent_port));
if req_path.exists() && self.pending_input_request.is_none() {
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 (tx, rx) = oneshot::channel();
self.pending_input_request = Some((prompt, tx));
self.input_value.clear();
let port = self.agent_port;
tokio::spawn(async move {
if let Ok(val) = rx.await {
let res_path = std::env::temp_dir().join(format!("rmon_auth_res_{}", port));
let _ = std::fs::write(&res_path, val);
}
});
}
}
while let Ok(msg) = self.ui_rx.try_recv() {
ctx.request_repaint();
self.process_ui_message(msg);
}
}
fn process_ui_message(&mut self, msg: UiMessage) {
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::Error(err) => {
self.add_log(format!("ERROR: {}", err));
self.last_error = Some(err);
}
UiMessage::Agent(AgentMessage::SignalList { signals }) => {
self.add_log(format!("Received signal list ({} signals)", signals.len()));
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 mut new_ids = Vec::new();
{
let state = self.state.lock().unwrap();
for batch in batches {
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::Agent(AgentMessage::Error { context, message }) => { self.add_log(format!("AGENT ERROR ({}): {}", context, message)); }
_ => {}
}
}
fn draw_auth_dialog(&mut self, ctx: &egui::Context) {
if self.pending_input_request.is_some() {
let prompt = self.pending_input_request.as_ref().unwrap().0.clone();
let mut open = true;
egui::Window::new("SSH Authentication Required").open(&mut open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.label(egui::RichText::new(&prompt).strong());
ui.add_space(8.0);
let is_password = prompt.to_lowercase().contains("password");
let text_edit = egui::TextEdit::singleline(&mut self.input_value).password(is_password);
let response = ui.add(text_edit);
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 let Some((_, tx)) = self.pending_input_request.take() { let _ = tx.send(self.input_value.clone()); }
}
});
if !open { self.pending_input_request = None; }
}
}
fn draw_connect_dialog(&mut self, ctx: &egui::Context) {
let mut open = self.show_connect_dialog;
egui::Window::new("Connect to Agent").open(&mut open).show(ctx, |ui| {
if let Some(err) = &self.last_error { ui.colored_label(egui::Color32::RED, format!("{}", err)); }
egui::Grid::new("connect_grid").num_columns(2).show(ui, |ui| {
ui.label("SSH Host:"); ui.text_edit_singleline(&mut self.ssh_host); ui.end_row();
ui.label("Agent Port:"); ui.add(egui::DragValue::new(&mut self.agent_port)); ui.end_row();
ui.label("Local TCP Addr:"); ui.text_edit_singleline(&mut self.addr); ui.end_row();
});
if ui.button("🚀 Connect").clicked() {
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 {
use crate::connection::SshConnection;
let ssh = SshConnection::new(ssh_host.clone(), agent_port);
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 skip_tunnel = is_localhost && local_port == agent_port;
let _ = ui_tx.send(UiMessage::Log("Preparing...".to_string())).await;
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; }
match ssh.check_running() {
Ok(false) => {
let _ = ui_tx.send(UiMessage::Log("Deploying and starting agent...".to_string())).await;
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;
}
_ => { let _ = ui_tx.send(UiMessage::Log("Agent already running".to_string())).await; }
}
if !skip_tunnel {
let _ = ui_tx.send(UiMessage::Log("Establishing SSH tunnel...".to_string())).await;
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; }
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; }
Err(e) => {
let _ = ui_tx.send(UiMessage::Error(format!("Handshake failed: {}", e))).await;
let _ = ssh.close_master();
let _ = ui_tx.send(UiMessage::Disconnected).await;
}
}
});
}
});
self.show_connect_dialog = open;
}
}
impl eframe::App for RMonApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.update_from_agent(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_config_editor {
let mut open = self.show_config_editor;
egui::Window::new("Agent Configuration").open(&mut open).show(ctx, |ui| {
ui.add(egui::TextEdit::multiline(&mut self.config_text).font(egui::TextStyle::Monospace).desired_width(f32::INFINITY));
if ui.button("💾 Save & Apply").clicked() {
if let Some(client) = &self.client {
let client = client.clone();
let config_toml = self.config_text.clone();
tokio::spawn(async move {
let _ = client.send(ClientMessage::UpdateConfig { config_toml }).await;
sleep(std::time::Duration::from_millis(1500)).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;
});
}
}
});
self.show_config_editor = open;
}
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();
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)]
mod tests {
use super::*;
use rmon_common::signal::{SignalInfo, SourceType};
#[test]
fn test_app_init() {
let (tx, rx) = mpsc::channel(100);
let 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![],
};
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);
}
}