use eframe::egui; use eframe::egui::Color32; use crate::connection::ProtocolClient; use crate::state::{SessionState, SignalHistory, PlotDefinition, SignalStyle}; use rmon_common::protocol::{ClientMessage, AgentMessage}; use rmon_common::signal::SignalId; use std::sync::{Arc, Mutex}; use tokio::sync::{mpsc, oneshot}; use tokio::time::sleep; use egui_plot::{Line, Plot, PlotPoints, VLine, Legend, Corner}; use std::collections::{VecDeque, HashMap}; use chrono::{Utc, Local, TimeZone}; #[derive(Clone)] pub enum UiMessage { Agent(AgentMessage), Connected(Arc), Tunnel(Arc>>), Disconnected, Error(String), Log(String), RequestInput { prompt: String, reply: Arc>>>, }, } pub struct RMonApp { ssh_host: String, agent_port: u16, addr: String, show_connect_dialog: bool, show_config_editor: bool, show_signals: bool, edit_mode: bool, config_text: String, last_error: Option, logs: VecDeque, pending_input_request: Option<(String, oneshot::Sender)>, input_value: String, // Explicit DND state dragged_signal: Option<(SignalId, String, Color32)>, state: Arc>, client: Option>, tunnel_child: Option>>>, ui_rx: mpsc::Receiver, ui_tx: mpsc::Sender, } 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, show_signals: true, edit_mode: false, config_text: String::new(), last_error: None, logs: VecDeque::with_capacity(100), pending_input_request: None, input_value: String::new(), dragged_signal: None, state: Arc::new(Mutex::new(SessionState::new())), client: None, tunnel_child: None, ui_rx: rx, ui_tx: tx, }; 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) { 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(); 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); let mut state = self.state.lock().unwrap(); state.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 }) => { 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); } } } _ => {} } } } fn draw_auth_dialog(&mut self, ctx: &egui::Context) { if let Some((prompt, _)) = &self.pending_input_request { let mut open = true; let prompt_clone = prompt.clone(); egui::Window::new("SSH Authentication Required").open(&mut open).show(ctx, |ui| { ui.label(egui::RichText::new(&prompt_clone).strong()); let is_password = prompt_clone.to_lowercase().contains("password"); let response = ui.add(egui::TextEdit::singleline(&mut self.input_value).password(is_password)); response.request_focus(); 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 _ = 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 is_localhost = ssh_host == "localhost" || ssh_host == "127.0.0.1"; let local_port = addr.split(':').last().and_then(|p| p.parse::().ok()).unwrap_or(7891); let skip_tunnel = is_localhost && local_port == agent_port; let _ = ui_tx.send(UiMessage::Log("Preparing SSH flow...".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("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 { 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; } } } let mut last_err = String::new(); for attempt in 1..=5 { let _ = ui_tx.send(UiMessage::Log(format!("Connection attempt {}/5...", attempt))).await; match ProtocolClient::connect(&addr, ui_tx.clone()).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; }); } }); self.show_connect_dialog = open; } } struct TreeBehavior<'a> { signals: HashMap>>, styles: &'a mut HashMap, client: &'a Option>, edit_mode: bool, dragged_signal: &'a Option<(SignalId, String, Color32)>, } impl<'a> egui_tiles::Behavior 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 { 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); } 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 { 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(1000)).await; let _ = client.send(ClientMessage::ListSignals).await; }); } } }); self.show_config_editor = open; } ctx.request_repaint_after(std::time::Duration::from_millis(50)); } } #[cfg(test)] mod tests { use super::*; use rmon_common::signal::SourceType; use rmon_common::signal::SignalInfo; #[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, show_signals: true, edit_mode: false, config_text: "".to_string(), last_error: None, logs: VecDeque::new(), pending_input_request: None, input_value: "".to_string(), dragged_signal: None, state: Arc::new(Mutex::new(SessionState::new())), client: None, tunnel_child: None, ui_rx: rx, ui_tx: tx, }; assert_eq!(app.ssh_host, "h"); } }